React で textarea のスクロールを同期する
はじめに
翻訳ツールの原文と訳文を並べて表示したり、エディターとプレビューを並べて表示したりする場合、スクロールの位置を同期すると見やすくなります。本記事では、これを React で実装する方法を解説します。
サンプル コード
実行手順
スクロール量の計算
スクロールを同期する方法はいくつかありますが、片方のスクロールの比率を取得して、もう片方に反映する方法が簡単です。どのくらいスクロールされているか (スクロールされたピクセル値) は 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);
// ...existing code...
return props.children({
ref1,
ref2
});
}
useRef したオブジェクトに textarea の要素が格納されているため、addEventListener メソッドで各種イベントを購読します。クリーンアップ時には removeEventListener メソッドを呼ぶことを推奨します。
React.useEffect(() => {
// ...existing code...
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