📝

textareaをカスタマイズできるReact用のライブラリを作成しました

2022/02/17に公開

HTMLのtextarea要素では、テキスト単位で色をつけたりイベントハンドリングしたりなどすることは、通常の方法では出来ないことは皆さんご存知かと思います。それを(擬似的に)可能にするライブラリを作成しました。
もしよろしければスター、使用した上でフィードバックなどいただけると非常にありがたいです。

https://github.com/inokawa/rich-textarea

Demo

https://inokawa.github.io/rich-textarea/

テキストを装飾したり(textareaに見えないかもしれないですがtextareaです)、

キャレットの位置にメニューを表示したり、

テキストにカーソルを乗せた時にTooltipを出したり、

アイデア次第で色々と出来ると思います(もちろん原理上不可能なことはありますが…)。
同様のことは、例えばSlateなどのライブラリを使用しても実現可能だと思いますが、こちらの方が断然軽量でバンドルサイズに何倍も差があります(現在約3.0kB gzipped)。なのでエディタライブラリを持ち出す程ではないけど、textareaでは物足りない、みたいなケースに向いているかなと思います。

使い方

通常のtextareaと基本的には同じで、いくつかpropsを追加しています。
propsについてはReadme、より詳細な使い方はexamplesを確認お願いします。

基本的な使い方として、下記のようにchildrenに(value: string) => React.ReactNodeを満たす関数を渡すことで、textarea内のテキストをスタイリングすることが出来ます。

import { useState } from "react";
import { RichTextarea } from "rich-textarea";

export const App = () => {
  const [text, setText] = useState("Lorem ipsum");

  return (
    <RichTextarea
      value={text}
      style={{ width: "600px", height: "400px" }}
      onChange={(e) => setText(e.target.value)}
    >
      {(v) => {
        return v.split("").map((t, i) => (
          <span key={i} style={{ color: i % 2 === 0 ? "red" : undefined }}>
            {t}
          </span>
        ));
      }}
    </RichTextarea>
  );
};

この関数は、本来textareaに表示されるべきテキストと、テキストが全く同じにレイアウトされているReactNodeを返却する必要があります。なので、ハイライトしたり取り消し線を引いたりはOKですが、例えば一部の文字だけfont-sizeを変更するのは、textareaとレイアウトがずれてしまうためできません。しかしこの条件さえ満たしていれば、特殊なtokenizerと組み合わせた複雑なレンダリングをすることも可能です。

これを毎回ゼロベースで作成するのは大変なので、正規表現からchildren用の関数を作成するhelperもexportしています。この例だとbackgroundColorなどを含むReact.CSSPropertiesを渡していますが、spanなどを渡すことも可能です。

import { useState } from "react";
import { RichTextarea, createRegexRenderer } from "rich-textarea";

const renderer = createRegexRenderer([
  [/[A-Z][a-z]+/g, { borderRadius: "3px", backgroundColor: "#d0bfff" }],
]);

export const App = () => {
  const [text, setText] = useState("Lorem ipsum");

  return (
    <RichTextarea
      value={text}
      style={{ width: "600px", height: "400px" }}
      onChange={(e) => setText(e.target.value)}
    >
      {renderer}
    </RichTextarea>
  );
};

children内のelementでは、onClickonMouseOverなど特定のイベントを受け取ることができ、イベントを契機にstateを更新してテキストに関連する情報を表示したり出来ます。

また、onSelectionChange propで現在のキャレット位置、キャレット座標を取得することが出来ます。こちらはキャレット位置にメニューを表示するのに使えると思います。

技術的な話

Webブラウザ上でtextarea以上のテキストエディタと呼べるものを実装する場合、選択肢に上がるのがcontenteditableだと思います。
が、一度触ったことがある方は分かると思いますが、これでエディタを作るのは非常にしんどいです。
ブラウザ間の実装差異や、DOMの直接編集などに起因する様々な問題があり、ゼロからエディタを作るのは現実的でない一方、大半のエディタライブラリはカスタマイズ性があまり高くない場合が多いです。
https://engineering.linecorp.com/ja/blog/uit-ridding-of-contenteditable/
https://www.bokukoko.info/entry/2017/10/08/154950

他方、textareaで実装できればこのような苦労はないですが、しかしこちらはテキスト単位で個別に装飾をしたり、イベントハンドリングをしたりすることができません。でも、大抵のケースでは、テキストに色をつけたり、テキストにカーソルを載せた時に何かを表示するといったちょっとしたインタラクションができれば事足りるはず…?
であれば、これをできるようにすれば良いのでは?というのが本試みの出発点です。そのために、あらゆるworkaroundを総動員している感じです。

テキストをスタイリングする

textareaには一律でstyleを設定することしかできず、個々の文字を装飾することはできません。なので、textareaを使わずtextareaっぽく見せています。
具体的には、textareaのstyleの内、テキストのサイズやレイアウトに影響すると思われるstyleを全てコピーしたdivを作成しています。この中にtextareaと全く同じテキストを逐一表示し、textareaを透過させた上で被せれば、理論上はtextareaの見た目のままテキストを装飾できることになります。実際はスクロールやリサイズなどtextareaとずれた見た目になってしまう要因が複数あるので、これらを個別に対応しています(もし問題を見つけた方はissueにてご報告いただけるとありがたいです)。

実装した後で知ったのですが、このstyleをコピーする方法ってtextareaでキャレット座標を取るための常套手段なんですね。なので、これが次のキャレット座標取得の実装に繋がっていきます。

https://qiita.com/yuku_t/items/fb92e173120d7b2e49ed

キャレット座標の取得

textareaにはselectionchange eventのような都合のいいものはない(一応あるみたいですが実装状況が…)ので、input eventなどの発火時にキャレット位置が変わったとみなしてstateを更新しています。ただ、実際にキャレット位置が変わるのはこれらのイベント発火時より微妙に後になるようので、良い感じに、タイミングはずらしています。
[2022/07/03 追記]
React 18でuseSyncExternalStoreが入ったので、この辺りの処理をuse-sync-external-store/shimを使って書き直しました。eventの発火前後でselectionが変わらなかった場合のrerenderingが抑制されていると思います。
https://www.npmjs.com/package/use-sync-external-store

また、selection rangeに対しgetBoundingClientRect()をすれば通常DOMRectで座標が取得できるのですが、textarea内では何故かDOMRectの全ての値が0になってしまいます。幸い、textareaのstyleをコピーしたdivを作成しているので、textareaのキャレット位置を基に、背面のdivからDOMRectを取ることで対応しています。divからtextareaと対応するrangeを取得するのにはNodeIteratorを使用しています(初めて使いました)。

テキスト上でイベントハンドリング

textareaの背面に要素を置いたり、前面にpointer-events: noneで要素を置いたりした場合、textareaにクリックなどのイベントは届きますが、当然これらの要素はイベントを受け取ることが出来ません。
方法はいくつかあると思うのですが、本ライブラリではtextareaの背面にテキストを配置していたこともあり、textareaを一瞬だけpointer-events: noneにした上でdocument.elementFromPoint()を使用することで背面の要素を取得し、これに対してdispatchEvent()でcloneしたMouseEventを投げてReactのイベントハンドラーを呼び出すことで、擬似的に背面の要素にイベントが届くようにしています。

https://qiita.com/legokichi/items/ba5e786f1dfc27ef2075

何故かmouseentermouseleaveだけはdispatchEventしてもReactのイベントハンドラーが検知してくれなかったので、これらのイベントは対応できていません(代わりにmouseovermouseoutは対応しています)。検索してみると同様の報告がいくつか見つかったので、事象としては存在するようです。

setRangeText

普通にsetRangeTextした上で、input eventを手動でdispatchEvent()してonChangeを発火させ、Reactのstateに変更結果を拾えるようにしています。

もしご存知の方がいたらお聞きしたいのですが、setRangeTextの結果をtextareaのundo stackに積む方法ってあるんでしょうか?
Document.execCommand('insertText', ...)であれば可能そうですが、execCommandはdeprecatedらしいのでどうしたものか…

IME関連

下記を参考に、IME入力中のselectionStart/selectionEndの値を補正しています。

https://javascripter.hatenablog.com/entry/2013/10/15/152216

また、IME周りといえばCompositionEventなど、キーボード入力に関連する各種イベントの発行タイミングがブラウザ毎に微妙に違う問題が有名かと思います。その辺りの差異もライブラリ側でハンドリングし扱いやすくしています。

https://qiita.com/darai0512/items/fac4f166c23bf2075deb
https://zoshigayan.net/how-to-get-key-from-ui-event/
https://www.stum.de/2016/06/24/handling-ime-events-in-javascript/

バンドルサイズ削減

mizchiさんの記事を非常に参考にさせていただいています。
https://zenn.dev/mizchi/scraps/fee64e76afc10d

objectは出来る限り使わない、同じ処理は1つの関数で共通化、同じ値も1つの変数で共通化、トランスパイルを出来る限り使わない(例えばnullish coalescingを使わず、falsyか否かで判定すれば十分な場合もあるはず)、といった形で切り詰めました。
代償として読みづらくはなりますが、趣味開発で他に誰が見るわけでもないので、バンドルサイズを優先しています。

Discussion