📝

contentEditable + Reactで簡易的なリッチエディタの自作

2024/07/06に公開

みなさんcontentEditable属性を使ったことはありますか?
使いこなせればリッチエディタの開発ができる反面、奇妙な挙動が大変多いです。
例えば、キャレット操作を自前実装で対応が求められるなどです。
更にReactでこの属性をそのまま使うと、子要素をJSXで指定することができません。

本稿ではこれらの辛いポイントを乗り越えて、簡易的なマークダウン風のリッチエディタを開発することを目指します。

対象読者

  • ライブラリを使用しないリッチエディタ開発に興味がある
  • contentEditableの子要素にJSXを使いたい
  • Reactチョットデキル

contentEditableの概要

contentEditable属性を要素につけると、編集可能にすることができます。つまり、div, p, h1タグなどの中身を、input, textareaタグのようにブラウザ上で編集することができます。

使い道として、リッチエディタの開発が挙げられます。
編集可能な領域に画像やアンカーなどの任意のタグ・自由なスタイルを追加したい場合、inputteaxareaでは実現できません。

既存のサービスで確認してみると、Zennの記事編集用のエディタにもcontentEditableがついていました。他にもNotion, はてなブログ, Noteなど探すと色々出てきます。

今回やりたいこと

イメージしやすくするために、先に最終的な成果物を載せます。

未対応の機能も多いですが、実装する機能は以下です。

  • contentEditableの中身をJSXで指定
    • #で始まる行はh1タグ, それ以外はpタグ
    • タグごとにスタイルを設定
  • キャレット操作に対応
  • 内部データに基本的なテキストを採用
  • IME対応

コード量が膨大のため、本稿ではポイントを絞って解説します。

step1: 愚直な方法

まずは単純な方法でcontentEditableをReactで扱います。
次の方針で実装してみます。

  • contentEditableの中身をHTMLとして取り出す
  • 更新されるたびにイベントを発火する

inputの制御コンポーネントと同じパターンです。違いは内部データにテキストではなくHTMLを扱っていることになります。

Editor.tsx
import styles from "./Editor.module.css";

type Props = {
  html: string;
  onChange: (html: string) => void;
};

export default function Editor({ html, onChange }: Props) {
  return (
    <div
      className={styles.editor}
      contentEditable="true"
      onInput={(e) => onChange(e.currentTarget.innerHTML)}
      dangerouslySetInnerHTML={{ __html: html }}
    ></div>
  );
}

このエディタに入力をすると、動作がおかしいことに気づくでしょう。
試しにTextと入力すると、txeTと表示されます。
これはDOMの更新に伴いキャレットの位置が初期化されることが原因です。

現状の足りない点をまとめると以下になります。

キャレットはcontentEditableの一つの砦となります。

step2: キャレット対応版

次の方針で実装します。

  • キャレットの位置を、「文頭から何回右に移動したか」とする
  • onInput中に、以下のステップで更新する
    1. 入力後のキャレット位置を保存
    2. flushSyncの中で同期的に更新
    3. キャレット位置の復元

DOM更新によりキャレットの位置が先頭になるため、前後に保存・復元のステップを挟むことで対応します。
状態更新を直ちに反映させるため、flushSyncを活用しています。

Editor.tsx
import { FormEvent, useRef } from "react";

import styles from "./Editor.module.css";
import { getCurrentPosition, syncUpdateContent } from "./lib/caret";

type Props = {
  html: string;
  onChange: (html: string) => void;
};

export default function Editor({ html, onChange }: Props) {
  const editorRef = useRef<HTMLDivElement>(null);

  const handleInput = (e: FormEvent) => {
    if (!editorRef.current) return;

    const el = editorRef.current;
    syncUpdateContent(el, getCurrentPosition(el), () => {
      onChange(e.currentTarget.innerHTML);
    });
  };

  return (
    <div
      className={styles.editor}
      contentEditable="true"
      onInput={handleInput}
      dangerouslySetInnerHTML={{ __html: html }}
      ref={editorRef}
      suppressContentEditableWarning
    ></div>
  );
}
lib/caret.ts
// 一部抜粋

export function syncUpdateContent(
  editorEl: HTMLElement,
  nextPosition: CaretPosition,
  callback: () => void
) {
  flushSync(callback);
  setCurrentRange(makeRange(editorEl, nextPosition));
}

syncUpdateContentのコールバックの中で呼ばれた更新は同期的に処理されるようにしました。
第2引数に次のキャレットの位置を渡すことで復元をしてくれます。

キャレット関連のメインロジックは、getCurrentPositionmakeRangeの2つあります。
RangeやSelectionをこねくり回して位置の取得・復元をしているのですが、少々複雑です。
ロジックの説明をすると長くなりすぎるので、興味がある方はコードを読んでみてください。

現状をまとめると以下になります。

step3: JSX対応版

contentEditableの中身をJSXで指定する問題点

前の2つの例では、contentEditableの中身をデフォルトの挙動に委ねていました。
しかしリッチエディタを開発するとなると、見出しはh1、太字はbで囲んで...みたいな構造や、スタイルの指定をしたくなるでしょう。
ひとつの方法として、contentEditableの中身に直接JSXを指定するやり方があります。

<div contentEditable="true">
    <h1 className={styles.heading}>Text</h1>
    <p>Text<b>Bold</b></p>
</div>

しかし単純にこの方法で実装すると、Reactがエラーを吐きます。
これはcontentEditableが編集するDOMはReactの管轄外[1]であることが原因です。

エラーを抑制する方法はsuppressContentEditableWarning属性をONにすれば良いのですが、これはエラーを表示しないだけで解決しません。

なので別の方法でcontentEditableの変更を管理しつつ、suppressContentEditableWarningをONにすることが求められます。

MutationObserverによる対応

上記の問題を解決するために、contentEditableの変更を全てロールバックします。
つまり、何らかの入力があった際に最終的な変更はReact管理のDOMだけになります。

変更を全てロールバックするにはMutationObserverという便利なものがあるので、これを使います。
これはWeb標準の機能で、DOMに関する変更を検知することができます。
onInputは変更後に呼ばれるイベントなので、この中で変更が発生していた場合はロールバックをします。

注意点として、ロールバック自体がDOM構造を変更する処理のため、MutationObserverをOFFにしてから実行する必要があります。
またReactによる変更も監視したくないため、状態更新時にもOFFにします。

lib/observer.ts ロールバック処理
// 一部抜粋

public rollback() {
    if (!this.isDisconnected) {
      throw new Error("should disconnect before rollback");
    }

    let mutation: MutationRecord | undefined;
    while ((mutation = this.__queue.pop())) {
      if (mutation.oldValue !== null) {
        mutation.target.textContent = mutation.oldValue;
      }

      for (let i = mutation.removedNodes.length - 1; i >= 0; i--)
        mutation.target.insertBefore(
          mutation.removedNodes[i],
          mutation.nextSibling
        );

      for (let i = mutation.addedNodes.length - 1; i >= 0; i--)
        if (mutation.addedNodes[i].parentNode)
          mutation.target.removeChild(mutation.addedNodes[i]);
    }
  }

内部データのフォーマット定義

これまでの例では内部データにHTMLを扱っていましたが、そのままでは加工しずらいです。
なので、今後のことも考えてJSXの構造と1:1で対応するデータフォーマットを作りましょう。

今回はマークダウン風のテキストとして考え、DOMの基本構造を次にします。

  • 子要素は一行に対応
  • #で始まる行はh1タグ、単純なテキストはpタグ
  • 空行は<p><br /><p>で表現 (contentEditableでキャレットを合わせるために<br />が必要)

Text\n\n#Textの例

<div contentEditable="true">
    <p>Text</p>
    <p><br /></p>
    <h1>#Text</h1>
</div>

「DOM->内部データ」変換

では上記で定義した構造への変換を考えます。まずはDOM -> 内部データです。
innerTextで手軽にできそうなのですが、今後のカスタマイズ性や厳密性も考慮してきちんと実装します。

lib/text.ts
import { isBrElement, isTextNode, traverse } from "./node";

export function serialize(editorEl: HTMLElement) {
  const children = [...editorEl.children];
  const contentList: string[] = [];

  for (const child of children) {
    let lineContent = "";

    traverse(child, (node) => {
      if (isTextNode(node)) {
        lineContent += node.textContent!;
      } else if (isBrElement(node)) {
        // do nothing
      }
    });

    contentList.push(lineContent);
  }

  const content = contentList.join("\n");

  return content;
}

traverseは行きがけ順でノードを処理します。
子要素の間に改行が入る前提なので、各子要素をルートにしています。
最後に各子要素のテキストを改行で結合して返します。

「内部データ->DOM(JSX)」変換

次に上記で生成した内部データからDOMを生成します。
処理は単純で、各行ごとに要素を指定しているだけです。

Content.tsx
import { Fragment } from "react";

import styles from "./Content.module.css";

type Props = {
  content: string;
};

export default function Content({ content }: Props) {
  const lines = content.split("\n");

  return (
    <>
      {lines.map((line, i) => {
        return (
          <Fragment key={i}>
            {line.startsWith("#") ? (
              <h1 className={styles.heading}>{line}</h1>
            ) : line !== "" ? (
              <p className={styles.text}>{line}</p>
            ) : (
              <p className={styles.text}>
                <br />
              </p>
            )}
          </Fragment>
        );
      })}
    </>
  );
}

処理の流れ

ここまでの処理の流れを追ってみます。

  1. キー入力
  2. onInputが発火
  3. 現在のDOMからテキストを取得
  4. MutationObserverをOFF
  5. flushSyncでロールバック・状態更新
  6. MutationObserverをON

その他

以下の細かいところに関しては、ソースコードを読んでください。

  • Enter・Backspaceなどの特殊なキー入力
  • IME対応

動作確認

最初と比べてだいぶ複雑な処理になりましたが、以下のようなコードになります。

Editor.tsx
import { FormEvent, useRef, KeyboardEvent } from "react";

import useObserver from "./hooks/useObserver";
import { serialize, insertTextByIndex, deleteTextByIndex } from "./lib/text";
import Content from "./Content";
import styles from "./Editor.module.css";
import { getCurrentPosition, syncUpdateContent } from "./lib/caret";

type Props = {
  text: string;
  onChange: (text: string) => void;
};

export default function Editor({ text, onChange }: Props) {
  const editorRef = useRef<HTMLDivElement>(null);
  const isComposingRef = useRef(false);

  const observer = useObserver(editorRef);

  const flushChanges = () => {
    if (!editorRef.current || !observer) return;

    const el = editorRef.current;
    if (observer.isUpdated()) {
      const content = serialize(el);
      observer.runWithoutObserver(() => {
        syncUpdateContent(el, getCurrentPosition(el), () => {
          observer.rollback();
          onChange(content);
        });
      });
    }
  };

  const handleKeyDown = (e: KeyboardEvent) => {
    if (!editorRef.current || !observer) return;

    const el = editorRef.current;
    if (e.key === "Enter") {
      if (isComposingRef.current) return;

      e.preventDefault();
      const currentPosition = getCurrentPosition(el);
      const prevContent = serialize(el);
      const content = insertTextByIndex(
        prevContent,
        "\n",
        currentPosition.position
      );

      const nextPosition = e.shiftKey
        ? currentPosition
        : currentPosition.add(1);

      observer.runWithoutObserver(() => {
        syncUpdateContent(el, nextPosition, () => {
          onChange(content);
        });
      });
    } else if (e.key === "Backspace") {
      if (isComposingRef.current) return;

      e.preventDefault();
      const currentPosition = getCurrentPosition(el);
      if (currentPosition.position === 0) return;

      const prevContent = serialize(el);
      const content = deleteTextByIndex(prevContent, currentPosition.position);

      observer.runWithoutObserver(() => {
        syncUpdateContent(el, currentPosition.add(-1), () => {
          onChange(content);
        });
      });
    }
  };

  const handleInput = (e: FormEvent) => {
    if (isComposingRef.current) return;
    flushChanges();
  };

  const handleCompositionStart = () => {
    isComposingRef.current = true;
  };

  const handleCompositionEnd = () => {
    isComposingRef.current = false;
    flushChanges();
  };

  return (
    <div
      className={styles.editor}
      contentEditable="true"
      onKeyDown={handleKeyDown}
      onInput={handleInput}
      onCompositionStart={handleCompositionStart}
      onCompositionEnd={handleCompositionEnd}
      ref={editorRef}
      suppressContentEditableWarning
    >
      <Content content={text} />
    </div>
  );
}

このエディタは完璧ではなく、改善点が沢山あります。

おわりに

contentEditableと格闘して、とりあえずカスタマイズ性を保った状態で動くところまで出来ました。
本稿はuse-editableのアーキテクチャに大きく影響を受けました。

ここらへんをうまく抽象化したライブラリとして、slateがあります。
カスタマイズ性も非常に高そうで、1からリッチエディタを開発する上での候補となるのではと思います。

またEditContext APIと呼ばれる新しい機能がchroniumベースのブラウザで追加されているみたいです。
以下の記事が大変参考になりました。

https://zenn.dev/cybozu_frontend/articles/5667796d4168bc

今回開発したエディタは簡易的なものでしたが、もし1から実装することになった場合に参考になれば嬉しいです。
ここまで読んでいただきありがとうございました!

脚注
  1. https://stackoverflow.com/questions/49639144/why-does-react-warn-against-an-contenteditable-component-having-children-managed ↩︎

Discussion