ReactでMarkdownエディタのスクロールを同期する方法
Markdown の入力欄とプレビューを横に並べたとき、スクロールの位置を全体に対する割合で合わせると、画像やコードブロックの前後で 250px ずれました。プレビューの見出しや段落に「Markdown の何行目から作られたか」を持たせ、入力欄とプレビューでの位置を組にして、そのあいだを比例配分すると、見出しどうしがぴったりそろいます。
あわせて、相手の欄から返ってきたスクロールを無視する処理と、画像が読み込まれたときに位置を測り直す処理も入れます。どちらも、抜くと目に見えて動きがおかしくなったので、その結果も載せています。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、react-markdown 10.1.0、remark-gfm 4.0.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。スクロールバーが幅を取る状態にしています。開発用のビルド(StrictMode あり)で操作しても、コンソールにエラーや警告は出ませんでした。Firefox と Safari、スマートフォンのブラウザ、日本語の変換中の入力、数千行を超える Markdown での速さは試していません。

全体の割合でそろえると、画像やコードの前後でずれる
いちばん簡単な合わせ方は、スクロールできる量に対する割合を、相手の欄にそのまま当てはめる書き方です。
const ratio = src.scrollTop / (src.scrollHeight - src.clientHeight);
dst.scrollTop = ratio * (dst.scrollHeight - dst.clientHeight);
試した Markdown には、表、縦長の画像、コードブロックを入れました。画像は Markdown では1行ですが、プレビューは画像の読み込みで 329px 高くなりました。そのため「3.」から「4.」の見出しまでの間隔は、入力欄の 204px に対して、プレビューでは 497px あります。逆に、コードブロックはプレビューのほうが文字が小さく、折り返さないので、「4.」から「5.」までの間隔は、入力欄の 561px に対して、プレビューでは 411px でした。
入力欄で各見出しの行を上端に合わせたとき、プレビューで同じ見出しが上端から何 px 離れたかを測りました。0px なら、ぴったりそろっています。
| 入力欄で上端に合わせた行 | 割合でそろえた場合 | 見出しの位置でそろえた場合 |
|---|---|---|
# 猫の手帳 |
7px | 0px |
## 1. ごはんの記録 |
26px | 0px |
## 2. 体重の表 |
1px | 0px |
## 3. 窓辺の写真 |
-21px | 0px |
## 4. 記録を集計するスクリプト |
250px | 0px |
## 5. 病院でのメモ |
40px | 0px |
## 6. 次にやること |
153px | 153px |
マイナスは、見出しが上端より上に隠れていることを表します。最後の「6.」は、どちらの書き方でも上端まで上げられない行です(あとで説明します)。割合でそろえると、画像の直後の「4.」の見出しで 250px ずれました。プレビュー側でスクロールしたときも、入力欄の行は最大で 226px ずれました。
下の画像は、幅を少し狭めた画面で同じように「4.」の行を上端に合わせたものです。割合でそろえた場合は、プレビューの上端にまだ写真が出ていて、見出しは 295px 下にありました。

プレビューのブロックに、元の行番号を持たせる
ずれをなくすには、「入力欄のこの行は、プレビューのこの要素」という対応が必要です。react-markdown は、Markdown を HTML の構文木(hast)に変えてから React の要素にします。その途中で hast を書き換える rehype プラグインを、rehypePlugins で渡せます。
表を出すために、README の使い方の例と同じく、remark-gfm を remarkPlugins に渡しています。
hast の各ノードには、元の文章のどこから作られたかが position として残っています。position.start.line は1から数えた行番号です。そこで、次のような小さなプラグインを書き、いちばん外側のブロック(見出し、段落、リスト、表、コードブロックなど)に data-line 属性として持たせました。
// プレビューのいちばん外側のブロックに、Markdown の何行目から作られたかを data-line で持たせる
function rehypeSourceLine() {
return (tree: Root) => {
for (const node of tree.children) {
if (node.type === 'element' && node.position) {
node.properties.dataLine = node.position.start.line;
}
}
};
}
const rehypePlugins = [rehypeSourceLine];
properties.dataLine に入れた値は、プレビューの HTML では <h2 data-line="5"> のような属性になり、querySelectorAll('[data-line]') で取り出せました。rehypePlugins の配列は、描画のたびに作り直さないよう、コンポーネントの外で作っています。
入力欄での行の位置は、写しの div で測る
プレビューの要素の位置は offsetTop で分かります。一方、textarea の中の文字は行ごとの要素に分かれていないので、行の offsetTop は取れません。日本語の段落は入力欄の中で折り返すので、行番号に行の高さを掛けても位置は出ません。試した Markdown は63行ですが、入力欄の中身の高さは 2,243px で、上下の padding を除いて1行 25.5px で割ると約87行ぶんありました。
そこで、textarea と同じ幅・同じ文字の指定で、Markdown を1行ずつ div に入れた「写し」を作り、その div の offsetTop を測ります。写しは visibility: hidden で見えなくし、aria-hidden="true" を付けています。
/* 入力欄と、行の位置を測るための写し。文字の並び方を決める指定はそろえる */
.md-input,
.md-mirror {
box-sizing: border-box;
margin: 0;
padding: 12px;
font: 15px/1.7 "Noto Sans CJK JP", sans-serif;
white-space: pre-wrap;
overflow-wrap: break-word;
}
写しの幅は、測るたびに textarea の clientWidth(スクロールバーの幅を除いた内側の幅)に合わせます。offsetTop は、いちばん近い位置指定のある祖先の、border のすぐ内側(padding を含む領域の上端)からの距離です。写しに position: absolute を付けておくと、行の div の offsetTop が、そのまま textarea の中での行の上端(scrollTop で使う座標)になります。
写しが正しく測れているかを、別の方法でも確かめました。1行目から N 行目の手前までを入れた textarea を作り、その scrollHeight から N 行目の上端を求めて、写しの値と比べています。63行のどの行でも差は 0.5px 以内で、textarea と写しの全体の高さも 2,243px で一致しました。画面の幅を 480px に縮め、折り返しが増えたあと(textarea の高さは 3,059px)でも、差は 0.5px 以内でした。
位置の組のあいだを比例配分する
測った位置は、{ editor, preview } の組の配列にします。先頭に (0, 0)、末尾に「両方のスクロールの最大値」を足しておき、スクロール位置が組と組のあいだにあれば、その区間の中で比例配分します。
// 入力欄とプレビューで、同じ場所を指すスクロール位置の組
export type Anchor = { editor: number; preview: number };
export type Pane = keyof Anchor;
// 組を、両方の位置が増えていく順になるものだけ残し、先頭(0, 0)と末尾(両方の最大値)を足す
export function buildAnchors(pairs: Anchor[], editorMax: number, previewMax: number): Anchor[] {
const anchors: Anchor[] = [{ editor: 0, preview: 0 }];
for (const p of pairs) {
const last = anchors[anchors.length - 1];
if (p.editor > last.editor && p.preview > last.preview && p.editor < editorMax && p.preview < previewMax) {
anchors.push(p);
}
}
anchors.push({ editor: Math.max(editorMax, 0), preview: Math.max(previewMax, 0) });
return anchors;
}
// from 側のスクロール位置 value を、前後の組のあいだで比例配分して、to 側の位置に直す
export function mapScroll(anchors: Anchor[], from: Pane, to: Pane, value: number): number {
for (let i = 1; i < anchors.length; i++) {
const a = anchors[i - 1];
const b = anchors[i];
if (value <= b[from] || i === anchors.length - 1) {
const span = b[from] - a[from];
const t = span > 0 ? Math.min(Math.max((value - a[from]) / span, 0), 1) : 0;
return a[to] + (b[to] - a[to]) * t;
}
}
return 0;
}
スクロールの最大値より下にある組は捨てています。最後のほうの見出しは、そこまでスクロールできないからです。試した Markdown では、最後の「6. 次にやること」の行を上端まで上げられず、入力欄もプレビューもいちばん下で止まりました。このとき、両方ともいちばん下まで来ているので、最後の数行は両方の欄に見えています。
同じ関数を、入力欄からプレビューへも、プレビューから入力欄へも使えます。プレビューで「1.」から「5.」の見出しを上端に合わせたときも、入力欄の行は 0px でそろいました。
部品のコード全体
部品の中身は、次の MarkdownEditor です。左に textarea と写し、右にプレビューを並べています。
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Root } from 'hast';
import { buildAnchors, mapScroll, type Anchor, type Pane } from './scrollMap';
// プレビューのいちばん外側のブロックに、Markdown の何行目から作られたかを data-line で持たせる
function rehypeSourceLine() {
return (tree: Root) => {
for (const node of tree.children) {
if (node.type === 'element' && node.position) {
node.properties.dataLine = node.position.start.line;
}
}
};
}
const remarkPlugins = [remarkGfm];
const rehypePlugins = [rehypeSourceLine];
type Props = {
value: string;
onChange: (value: string) => void;
};
export function MarkdownEditor({ value, onChange }: Props) {
const editorRef = useRef<HTMLTextAreaElement>(null);
const mirrorRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const anchorsRef = useRef<Anchor[]>([]);
// 自分でスクロールさせた要素と、そのときの scrollTop
const expectedRef = useRef<Record<Pane, number | null>>({ editor: null, preview: null });
// 見出しや段落ごとに、入力欄での位置とプレビューでの位置を測って組にする
const measure = useCallback(() => {
const editor = editorRef.current!;
const mirror = mirrorRef.current!;
const preview = previewRef.current!;
mirror.style.width = `${editor.clientWidth}px`;
const lines = mirror.children as HTMLCollectionOf<HTMLElement>;
const pairs: Anchor[] = [];
for (const el of preview.querySelectorAll<HTMLElement>('[data-line]')) {
const line = lines[Number(el.dataset.line) - 1];
if (line) pairs.push({ editor: line.offsetTop, preview: el.offsetTop });
}
anchorsRef.current = buildAnchors(
pairs,
editor.scrollHeight - editor.clientHeight,
preview.scrollHeight - preview.clientHeight,
);
}, []);
// 文字を入力したら測り直す
useLayoutEffect(measure, [value, measure]);
// 画像の読み込みや幅の変化で高さが変わったときも測り直す
useEffect(() => {
const observer = new ResizeObserver(measure);
observer.observe(editorRef.current!);
observer.observe(bodyRef.current!);
return () => observer.disconnect();
}, [measure]);
const sync = (from: Pane) => {
const to: Pane = from === 'editor' ? 'preview' : 'editor';
const src = from === 'editor' ? editorRef.current! : previewRef.current!;
const dst = to === 'editor' ? editorRef.current! : previewRef.current!;
// こちらから動かしたスクロールなら、相手へ送り返さない
const expected = expectedRef.current[from];
expectedRef.current[from] = null;
if (expected !== null && Math.abs(src.scrollTop - expected) < 1) return;
dst.scrollTop = mapScroll(anchorsRef.current, from, to, src.scrollTop);
expectedRef.current[to] = dst.scrollTop;
};
return (
<div className="md-editor">
<div className="md-input-wrap">
<textarea
ref={editorRef}
className="md-input"
aria-label="Markdown"
value={value}
spellCheck={false}
onChange={(e) => onChange(e.target.value)}
onScroll={() => sync('editor')}
/>
<div ref={mirrorRef} className="md-mirror" aria-hidden="true">
{value.split('\n').map((line, i) => (
<div key={i}>{line === '' ? ' ' : line}</div>
))}
</div>
</div>
<div ref={previewRef} className="md-preview" onScroll={() => sync('preview')}>
<div ref={bodyRef}>
<Markdown remarkPlugins={remarkPlugins} rehypePlugins={rehypePlugins}>{value}</Markdown>
</div>
</div>
</div>
);
}
写しの空の行には、空白を1つ入れています。写しの中で測ると、中身のない div の高さは 0px で、空白を1つ入れた div は 26px(1行ぶん)でした。空のままだと、textarea の空の行と高さが合いません。
位置の組は、文字を入力するたびに useLayoutEffect の中で作り直します。useLayoutEffect は、React が DOM を書き換えたあと、ブラウザが画面を描き直す前に呼ばれるので、入力した文字が写しとプレビューに入った状態で測れます。冒頭の段落のあとに、改行2つと長い段落を入力してから測ると、textarea と写しの高さはどちらも 2,370px になり、最後の「6.」を除く見出しは 0px でそろったままでした。
CSS の残りは次のとおりです。写しを入れる .md-input-wrap と、プレビューの .md-preview に position: relative を付け、offsetTop をその要素から測れるようにしています。
.md-editor {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
height: 420px;
}
.md-input-wrap {
position: relative;
overflow: hidden;
min-width: 0;
}
.md-input {
width: 100%;
height: 100%;
border: 1px solid #9aa5ab;
border-radius: 6px;
resize: none;
overflow-y: auto;
color: #1f2a30;
}
.md-mirror {
position: absolute;
top: 0;
left: 0;
border: 0;
visibility: hidden;
pointer-events: none;
}
.md-preview {
position: relative; /* 中のブロックの offsetTop を、この要素から測る */
min-width: 0;
overflow-y: auto;
padding: 0 16px;
border: 1px solid #9aa5ab;
border-radius: 6px;
font: 15px/1.7 "Noto Sans CJK JP", sans-serif;
color: #1f2a30;
}
.md-preview img {
max-width: 100%;
height: auto;
}
.md-preview pre {
overflow-x: auto;
padding: 8px;
background: #eefafd;
font: 13px/1.5 "Noto Sans Mono CJK JP", monospace;
}
.md-preview table {
border-collapse: collapse;
}
.md-preview th,
.md-preview td {
border: 1px solid #9aa5ab;
padding: 2px 8px;
}
使う側では、Markdown の文字列を useState で持って渡します。sample は、試した Markdown を入れた文字列です。
import { useState } from 'react';
import { MarkdownEditor } from './MarkdownEditor';
import './MarkdownEditor.css';
import { sample } from './sample';
export function App() {
const [text, setText] = useState(sample);
return <MarkdownEditor value={text} onChange={setText} />;
}
相手から返ってきたスクロールは送り返さない
scrollTop に値を入れると、behavior: "auto" で scroll() を呼んだときと同じようにその要素がスクロールします。検証環境では、値を入れた要素でも scroll イベントが起きました。入力欄を動かすとプレビューが動き、プレビューの scroll イベントで、今度は入力欄に値が入ります。
sync から、expectedRef を使う4行を抜いた書き方で試しました。入力欄で scrollTo({ top: 900, behavior: 'smooth' }) を呼び、なめらかにスクロールさせると、入力欄は 2px 動いたところで止まりました。
[naive] 入力欄で scrollTo({ top: 900, behavior: 'smooth' }): scroll イベント 4回 / 入力欄の最後の scrollTop=2 プレビュー=3
[naive] 順番: 入2 プ3 入2 入2
「入」は入力欄、「プ」はプレビューで起きた scroll イベントで、続く数字はそのときの scrollTop です。プレビューから送り返された値が入力欄の scrollTop に入り、途中だったスクロールが止まっています。
完成形では、相手の欄を動かしたときに、その欄の scrollTop を expectedRef に覚えておきます。その欄で次に scroll イベントが起きたとき、scrollTop と覚えた値との差が1px 未満なら、自分が動かしたスクロールとみなして送り返しません。同じ操作で、入力欄は 900px まで動きました。
[final] 入力欄で scrollTo({ top: 900, behavior: 'smooth' }): scroll イベント 58回 / 入力欄の最後の scrollTop=900 プレビュー=1149
覚えた値は、次の scroll イベントで必ず消しています。代入しても scrollTop が変わらず、イベントが起きなかった場合でも、次に利用者がその欄をスクロールしたときには値が違うので、そのスクロールは相手へ伝わります。
画像が読み込まれたら測り直す
プレビューの画像は、読み込みが終わるまで高さが決まりません。試した Markdown では、画像を読み込む前のプレビューの高さが 2,109px、読み込んだあとが 2,438px でした。
ResizeObserver の部分を抜き、文字を入力したときだけ測る書き方で、画像を1秒遅らせて返して試しました。画像より前の見出しは 0px でそろいますが、画像より後ろは、入力欄からプレビューへ合わせたときに 330px ずれました。入力欄をいちばん下までスクロールしても、プレビューは 1,691px で止まり、最大の 2,020px まで届きませんでした。
[noobserver] 入力欄→プレビュー ## 4. 記録を集計するスクリプト: 入力欄 scrollTop=1007 プレビュー scrollTop=1035 見出しと上端の差=330px
[noobserver] 入力欄をいちばん下まで: 入力欄 scrollTop=1825 プレビュー scrollTop=1691(最大 2020)
完成形では、ResizeObserver で、プレビューの中身を包む div(bodyRef)と textarea の大きさを見張り、変わったら measure を呼び直しています。画像を同じように遅らせても、最後の「6.」を除く見出しは 0px でそろい、入力欄をいちばん下まで動かすと、プレビューも 2,020px まで動きました。
textarea も見張っているのは、画面の幅が変わると折り返しが変わり、写しの幅も合わせ直す必要があるからです。画面の幅を 640px から 480px に縮めたあとも、写しの幅は textarea の内側と同じ 201px になり、最後の「6.」を除く見出しは 0px でそろっていました。





