ReactでMarkdownエディタのスクロールを同期する方法

​​React​Markdown,Playwright,react-markdown,TypeScript

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 での速さは試していません。

左に Markdown の入力欄、右にプレビューが並んだ画面。入力欄の上でホイールを4回(1回は少しずつ6回に分けて)回すと、入力欄とプレビューが一緒に下へ動く。カーソルをプレビューへ移してホイールを3回下へ、2回上へ回すと、入力欄も一緒に動き、プレビューで写真の節を通るあいだは、入力欄はゆっくり動く。下の scrollTop の数字は、変わった瞬間だけピンクになる
入力欄でホイールを回したあと、プレビューでも回す様子。上の説明・回数・カーソル・下の数字は GIF 用に重ねたもの

全体の割合でそろえると、画像やコードの前後でずれる

いちばん簡単な合わせ方は、スクロールできる量に対する割合を、相手の欄にそのまま当てはめる書き方です。

tsx
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 下にありました。

上下2つの画面を並べた画像。上は割合でそろえた場合で、入力欄の上端は「## 4. 記録を集計するスクリプト」なのに、プレビューの上端には猫の絵の下のほうが出ていて、「4. 記録を集計するスクリプト」の見出しはずっと下にある。下は見出しの位置でそろえた場合で、プレビューの上端にも「4. 記録を集計するスクリプト」が出ている
入力欄の「## 4.」の行を上端に合わせたときのプレビュー。上が割合でそろえた場合(ピンクの枠がプレビューの見出し)、下が完成形

プレビューのブロックに、元の行番号を持たせる

ずれをなくすには、「入力欄のこの行は、プレビューのこの要素」という対応が必要です。react-markdown は、Markdown を HTML の構文木(hast)に変えてから React の要素にします。その途中で hast を書き換える rehype プラグインを、rehypePlugins で渡せます。

表を出すために、README の使い方の例と同じく、remark-gfm を remarkPlugins に渡しています。

hast の各ノードには、元の文章のどこから作られたかが position として残っています。position.start.line は1から数えた行番号です。そこで、次のような小さなプラグインを書き、いちばん外側のブロック(見出し、段落、リスト、表、コードブロックなど)に data-line 属性として持たせました。

tsx
// プレビューのいちばん外側のブロックに、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" を付けています。

css
/* 入力欄と、行の位置を測るための写し。文字の並び方を決める指定はそろえる */
.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)、末尾に「両方のスクロールの最大値」を足しておき、スクロール位置が組と組のあいだにあれば、その区間の中で比例配分します。

ts
// 入力欄とプレビューで、同じ場所を指すスクロール位置の組
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 と写し、右にプレビューを並べています。

tsx
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 をその要素から測れるようにしています。

css
.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 を入れた文字列です。

tsx
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 動いたところで止まりました。

text
[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 まで動きました。

text
[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 まで届きませんでした。

text
[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 でそろっていました。

​Posted by 86