🔗

React で textarea のスクロールを同期する

2023/10/13に公開

はじめに

翻訳ツールの原文と訳文を並べて表示したり、エディターとプレビューを並べて表示したりするときに、スクロールの位置を同期すると見やすいです。今回はこれを React で実装してみます。

サンプル コード

https://github.com/karamem0/samples/tree/main/react-sync-scrolling

実行手順

スクロール量の計算

スクロールを同期する方法はいくつか考えられるのですが、片方のスクロールの比率を取得して、もう片方に反映するというのが簡単です。どのくらいスクロールされているか (スクロールされたピクセル値) は scrollTop プロパティで取得できます。スクロールの比率を考えると重要になってくるのは scrollTop プロパティの最大値がいくつになるかということです。最大値がわかればそこから全体の何パーセントかを割り出すことができます。

スクロールの最大値、つまり一番下までスクロールしたときの値は、clientHeight プロパティおよび scrollHeight プロパティから算出できます。clientHeight はユーザーに見えている部分の高さ、scrollHeight はユーザーに見えていない部分も含めた高さです。よって scrollHeight から clientHeight を引いた値がスクロールの最大値になります。たとえば、clientHeight が 600px、scrollHeight が 800px であれば、スクロールの最大値は 200px です。あとは scrollTop をスクロールの最大値で割れば比率が求められます。

const clientHeight1 = textarea1.clientHeight;
const scrollHeight1 = textarea1.scrollHeight;
const scrollTop1 = textarea1.scrollTop;
const scrollRate = scrollTop1 / (scrollHeight1 - clientHeight1);
const clientHeight2 = textarea2.clientHeight;
const scrollHeight2 = textarea2.scrollHeight;
const scrollTop2 = scrollRate * (scrollHeight2 - clientHeight2);
textarea2.scrollTo({ top: scrollTop2 });

イベントの連鎖の防止

scroll イベントで発火した要素に対して上記の計算をした上で、もう片方の要素の scrollTo メソッドを呼び出します。しかし scrollTo メソッドは scroll イベントを発火するので、そのままだとイベントが無限ループになってしまいます。これを防ぐために、アクティブな要素に対してのみ scroll イベントを処理する必要があります。今回は mouseenter イベントおよび mouseleave イベントを使ってアクティブかどうかを判定します。useState を使ってしまうとレンダリングが発生してややこしいことになるので useRef を使うのを忘れずに。

const active1 = React.useRef<boolean>(false);

const handleMouseEnter1 = React.useCallback(() => {
  active1.current = true;
}, []);

const handleMouseLeave1 = React.useCallback(() => {
  active1.current = false;
}, []);

イベントの購読

素直に React のイベントを受け取ってもいいのですが煩雑になりそうなので、Render Props のパターンを使って useRef したオブジェクトを子のコンポーネントに受け渡します。

interface ScrollSynchronizerProps {
  children: (state: ScrollSynchronizerState) => React.ReactNode
}

interface ScrollSynchronizerState {
  ref1: React.Ref<HTMLTextAreaElement>,
  ref2: React.Ref<HTMLTextAreaElement>
}

function ScrollSynchronizer(props: ScrollSynchronizerProps) {

  const ref1 = React.useRef<HTMLTextAreaElement>(null);
  const ref2 = React.useRef<HTMLTextAreaElement>(null);

  ...

  return props.children({
    ref1,
    ref2
  });

}

useRef したオブジェクトに textarea の要素が入っているはずなので addEventListener メソッドで各種イベントを購読します。クリーンアップの際に removeEventListener メソッドを呼んであげるといいでしょう。

React.useEffect(() => {
  ...
  textarea1.addEventListener('mouseenter', handleMouseEnter1);
  textarea1.addEventListener('mouseleave', handleMouseLeave1);
  textarea1.addEventListener('scroll', handleScroll1);
  textarea2.addEventListener('mouseenter', handleMouseEnter2);
  textarea2.addEventListener('mouseleave', handleMouseLeave2);
  textarea2.addEventListener('scroll', handleScroll2);
  return () => {
    textarea1.removeEventListener('mouseenter', handleMouseEnter1);
    textarea1.removeEventListener('mouseleave', handleMouseLeave1);
    textarea1.removeEventListener('scroll', handleScroll1);
    textarea2.removeEventListener('mouseenter', handleMouseEnter2);
    textarea2.removeEventListener('mouseleave', handleMouseLeave2);
    textarea2.removeEventListener('scroll', handleScroll2);
  };
}, []);

実行結果

試しに走れメロスを並べて表示させてみます。

スクロールさせてみると同期されていることが確認できます。

おわりに

自前で実装せずとも react-scroll-sync というパッケージもあります。

Discussion