Reactで、行番号付きのテキストエリアを作る方法

​​React​CSS,Playwright,textarea,TypeScript

textarea には行番号を出す機能がないので、行番号を縦に並べた div を textarea の左に置きます。番号は入力した文字列の改行の数から作り、textarea をスクロールしたら、その分だけ番号の列も動かします。折り返しを止めて「1行=1つの番号」にしておけば、番号と行の位置がずれません。

この記事では、設定ファイルを書くような入力欄を作り、キャレットのある行の番号を太字にしました。番号の列の動かし方とキャレットの行の求め方には、試してみて分かった落とし穴があったので、その結果も載せています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。Playwright がヘッドレスのときに付ける --hide-scrollbars を外し、スクロールバーが幅を取る状態で試しています。開発用のビルド(StrictMode あり)で操作しても、コンソールにエラーや警告は出ませんでした。Firefox と Safari、スマートフォンのブラウザ、日本語の変換中の入力、スクリーンリーダーでの読み上げ、数千行を超える文字列での速さは試していません。

6行の設定を入れた入力欄で、Enter を押して「file = "app.log"」「rotate = 7」、空の行、「[cache]」と書き足すと、行番号が7から10まで増え、入力欄がスクロールすると番号の列も一緒に上に動く。↑ を3回押すと、太字の番号が10から7へ移る。ホイールで上にスクロールすると、番号の列も1行目まで戻る。2行目をクリックすると、太字の番号が2になる
行を書き足す、↑ で行を移る、スクロールする、行をクリックする様子。周りの説明・回数・キー・カーソルと、入力欄の下の行数の表示は GIF 用に重ねたもの。数字は変わった瞬間だけピンクにしている

行番号の列と textarea を横に並べる部品

部品の中身は、次の LineNumberTextarea です。外側の div の中に、行番号の列と textarea を横に並べています。textarea は、公式リファレンスにある、state で textarea の値を持つ例と同じく、value と onChange で値を管理しています。

tsx
import { useEffect, useRef, useState, type CSSProperties, type UIEvent } from 'react';

type Props = {
  id: string;
  value: string;
  onChange: (value: string) => void;
  rows?: number;
};

// キャレットがある行の番号(1から数える)
function caretLine(el: HTMLTextAreaElement) {
  const pos = el.selectionDirection === 'backward' ? el.selectionStart : el.selectionEnd;
  return el.value.slice(0, pos).split('\n').length;
}

export function LineNumberTextarea({ id, value, onChange, rows = 8 }: Props) {
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const numbersRef = useRef<HTMLDivElement>(null);
  const [current, setCurrent] = useState(1);
  const count = value.split('\n').length;

  // キャレットが動くたびに、キャレットのある行を求め直す
  useEffect(() => {
    const el = textareaRef.current!;
    const update = () => setCurrent(caretLine(el));
    el.addEventListener('selectionchange', update);
    return () => el.removeEventListener('selectionchange', update);
  }, []);

  // textarea のスクロールに合わせて、行番号の列を上にずらす
  const handleScroll = (e: UIEvent<HTMLTextAreaElement>) => {
    const top = e.currentTarget.scrollTop;
    if (numbersRef.current) numbersRef.current.style.transform = `translateY(${-top}px)`;
  };

  return (
    <div className="ln-wrap" style={{ '--digits': String(count).length } as CSSProperties}>
      <div className="ln-gutter" aria-hidden="true">
        <div ref={numbersRef}>
          {Array.from({ length: count }, (_, i) => (
            <div key={i} className={i + 1 === current ? 'ln-current' : undefined}>
              {i + 1}
            </div>
          ))}
        </div>
      </div>
      <textarea
        ref={textareaRef}
        id={id}
        className="ln-input"
        value={value}
        rows={rows}
        spellCheck={false}
        onChange={(e) => onChange(e.target.value)}
        onScroll={handleScroll}
      />
    </div>
  );
}

行の数は value.split('\n').length で数えます。空の文字列でも1になるので、何も入力していない状態でも「1」が出ます。

行番号の列には aria-hidden="true" を付けて、アクセシビリティのツリーから外しています。「a = 1」「b = 2」の2行を入れて Playwright でツリーを書き出すと、番号は出てこず、ラベル付きの textbox が1つだけになりました。

text
- text: 設定ファイル
- textbox "設定ファイル": a = 1 b = 2
- button "クリア"

使う側では、文字列を useState で持って渡すだけです。

tsx
import { useState } from 'react';
import { LineNumberTextarea } from './LineNumberTextarea';

export function App() {
  const [code, setCode] = useState('');

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <label htmlFor="code">設定ファイル</label>
      <LineNumberTextarea id="code" value={code} onChange={setCode} />
      <button type="button" onClick={() => setCode('')}>
        クリア
      </button>
    </form>
  );
}

Enter で20行まで書き足し、textarea がスクロールした状態で「クリア」を押すと、textarea の scrollTop が 0 に戻り、番号の列も translateY(0px) に戻りました。番号は「1」だけになり、太字も1行目に移ります。

行の高さをそろえ、折り返しを止める CSS

番号と行の位置を合わせるために、font(大きさと行の高さ)と上下の padding を、行番号の列と textarea で同じにします。共通の指定は1つのルールにまとめました。

css
.ln-wrap {
  display: flex;
  border: 1px solid #9aa5ab;
  border-radius: 6px;
  overflow: hidden;
  background: #fff;
}
.ln-wrap:focus-within {
  outline: 2px solid #00c8e8;
  outline-offset: 1px;
}

/* 行の高さを決める指定は、行番号の列と textarea で必ずそろえる */
.ln-gutter,
.ln-input {
  margin: 0;
  padding-block: 8px;
  font: 14px/1.6 "Noto Sans Mono CJK JP", monospace;
}

.ln-gutter {
  flex: none;
  contain: size; /* 行番号の数で高さが伸びないようにし、高さは textarea に合わせる */
  overflow: hidden;
  width: calc(var(--digits) * 1ch);
  min-width: 2ch;
  padding-inline: 8px;
  text-align: right;
  color: #9aa5ab;
  background: #eefafd;
  user-select: none;
}
.ln-current {
  color: #067a98;
  font-weight: bold;
}

.ln-input {
  flex: 1;
  min-width: 0;
  padding-inline: 8px;
  border: 0;
  outline: none;
  resize: vertical;
  white-space: pre;
  overflow: auto;
  color: #1f2a30;
}

contain: size は、中身を無視して要素の大きさを決める指定です。これがないと、flex で横に並べた textarea が、番号の列の高さまで引き伸ばされます。30行を入れて比べると、contain: size があるときの textarea の高さは 195px(rows={8} の8行ぶん)、外すと 688px で、30行がすべて見える高さまで伸びました。

番号の列の幅は、桁数を CSS 変数 --digits で渡し、ch 単位で決めています。ch は、そのフォントの「0」の幅です。行数を変えて、いちばん大きい番号の幅と、列の内側の幅を比べました。

行数 最後の番号の幅 列の内側の幅
9行 14px 14px(min-width: 2ch)
99行 14px 14px
100行 21px 21px
1000行 28px 28px

どの行数でも、番号は列の中に収まりました。99行から100行に増えた瞬間に、列が1文字ぶん広がり、そのぶん textarea が狭くなります。

番号の列には user-select: none も付けました。ページの左上から「クリア」のボタンの下までドラッグして選択すると、選択された文字列は "設定ファイル\n\nクリア" で、番号は入りませんでした。

折り返しは、textarea に white-space: pre を指定して止めています。MDN の textarea の説明では、wrap="off" は標準ではない値で、見た目を white-space: pre にするものとされています。そこで、この記事では属性を使わず、CSS で指定しました。長い行のある4行を入れると、textarea の中は4行のままで、横に 1,017px はみ出してスクロールできる状態になりました。

番号の列は scrollTop ではなく transform で動かす

番号の列の scrollTop に、textarea と同じ値を入れて動かす書き方もあります。この書き方では、横にはみ出す行があると、いちばん下までスクロールしたときに番号が15px ずれました。

30行のうち5行目だけが横にはみ出す文字列を入れ、ホイールでいちばん下までスクロールして比べた結果が次のとおりです。

text
[scrolltop・long] textarea: scrollTop=508 scrollHeight=688 clientHeight=180
[scrolltop・long] 行番号の列: scrollTop=493 scrollHeight=688 clientHeight=195 transform=なし
[scrolltop・long] 行番号と行の上端の差(最大): 15px、列のいちばん下に見える行番号: 30

textarea の下には横のスクロールバーが出るので、中身が見える高さ(clientHeight)は 180px になります。番号の列にはスクロールバーがなく、195px のままです。中身の高さはどちらも 688px なので、スクロールできるのは textarea が 508px まで、番号の列は 493px までです。番号の列は 493px で止まり、差の 15px だけ番号が下にずれました。横にはみ出す行がない30行では、どちらも 493px までスクロールでき、ずれは出ませんでした。

transform: translateY() で動かす完成形では、番号の列の scrollTop の上限に関係なく、同じ文字列でも translateY(-508px) まで動きました。行番号と行の上端の差は、いちばん大きいところでも 0.3px です。

text
[final・long] textarea: scrollTop=508 scrollHeight=688 clientHeight=180
[final・long] 行番号の列: scrollTop=0 scrollHeight=688 clientHeight=195 transform=translateY(-508px)
[final・long] 行番号と行の上端の差(最大): 0.3px、列のいちばん下に見える行番号: 30
3つの入力欄を縦に並べた画像。1つ目は scrollTop で番号の列を動かしたもので、いちばん下までスクロールすると、番号が行より少し下にずれ、「30」が横のスクロールバーの横に出ている。2つ目は transform で動かしたもので、「30」が「key30 = 30」の横にそろっている。3つ目は折り返しを止めないもので、2行目の長い文が5行に折り返され、「3」「4」の番号が2行目の続きの横に出ている
番号の列を scrollTop で動かした場合(上)、transform で動かした場合(中)、折り返しを止めない場合(下)。上と下は比べるために作ったもので、キャレットのある行を太字にする処理は入れていない

translateY の値は、React の state を通さずに、ref で取った要素の style.transform に直接書いています。Enter で20行まで書き足したときも、textarea が自分でスクロールした分(scrollTop=260)だけ、番号の列も translateY(-260px) に動いていました。

折り返しを止めないと、番号が行からずれる

上の画像の3つ目は、white-space: pre を外して、textarea の中で長い行を折り返させたものです。番号は改行の数(4つ)しか出ないのに、見た目の行は8行あります。折り返した2行目の続きの横に「3」「4」が並び、本当の3行目と4行目には番号が付きません。

この記事のコードは改行の数だけ番号を出すので、折り返す入力欄にはそのままでは使えません。コードを書く入力欄を想定して、折り返しを止めています。

キャレットのある行は selectionchange で求める

キャレットのある行の番号は、キャレットの位置より前にある改行の数に1を足して求めます。キャレットの位置には、ふだんは選択の終わり(selectionEnd)を使います。selectionDirection が "backward"(後ろから前へ選択している)のときだけ、選択の始まり(selectionStart)を使います。3行目の末尾から Shift+↑ で1行目まで選択すると、太字は1行目になりました。

React の onSelect と onChange で求め直す書き方も試しました。公式リファレンスの onSelect の説明では、選択が変わったあとに呼ばれ、選択が空のときや編集のときにも呼ばれるとされています。入力、↑ キー、Ctrl+End、Shift+↑ での選択では、正しい行になりました。ところが、同じように3行目の末尾から1行目まで選択したあと、選択範囲の中の2行目をクリックすると、キャレットは2行目に移ったのに、太字は1行目のまま残りました。

text
--- キャレットのある行(onselect)---
Shift+↑ を2回(後ろから前へ選択): 1
選択範囲の中の2行目をクリック: 1 (キャレットは2行目)

そこで、textarea で発生する selectionchange イベントを受け取るようにしました。useEffect の中で、ref で取った textarea に addEventListener で登録しています。同じ操作をすると、太字も2行目に移りました。

text
--- キャレットのある行(final)---
Shift+↑ を2回(後ろから前へ選択): 1
選択範囲の中の2行目をクリック: 2 (キャレットは2行目)
注意

textarea で selectionchange が発生するのは、MDN の互換性の表によると Chrome 127、Firefox 92、Safari 18 からです。それより前の Chrome と Safari では、このイベントは textarea ではなく document で発生します。この記事のコードは textarea に登録しているので、対象はそれ以降のブラウザです。

​Posted by 86