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);
...
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