Reactで、選んだ文字を太字にするツールバー付きの入力欄を作る方法

​​React​contenteditable,Playwright,TypeScript,アクセシビリティ

選んだ文字を太字にする入力欄は、contentEditable を付けた div と、document.execCommand('bold') を呼ぶボタンで作れます。React で書くときに気をつけたいのは、入力欄の中身を React に描き直させないことです。描き直させると、1文字打つたびにキャレットが先頭へ飛びます。ボタンは onMouseDown で preventDefault() して、押してもフォーカスが入力欄から離れないようにします。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、@types/react 19.3.0、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright でヘッドレスの Chromium を操作しました。Firefox と Safari、スマートフォンのタッチ操作、日本語入力の変換中の操作、スクリーンリーダーでの実際の読み上げは試していません。

「猫」を選んで B を押すと太字になり、下の HTML に b 要素が入る。太字の「よく眠る」を選んで B を押すと太字が外れる。文の最後で B を押してから「とても」と入力すると、太字で入る。B ボタンは、キャレットや選択範囲が太字の中にあるあいだ水色になる。変わった HTML の表示が、その瞬間だけピンクになる
選んで B を押す、太字の部分を選んで B を押す、何も選ばずに B を押してから入力する、の3回の操作(輪はマウスの位置で、押したときは太くなる。輪と上の表示は GIF 用に重ねたもの)

コンポーネントの全体

ツールバーの B ボタンと、contentEditable の入力欄を1つのコンポーネントにまとめました。入力欄の中身が変わるたびに、onChange で HTML の文字列を親に渡します。

tsx
import { useCallback, useEffect, useRef, useState } from 'react';

type Props = {
  initialHtml?: string;
  onChange: (html: string) => void;
};

export function BoldEditor({ initialHtml = '', onChange }: Props) {
  const editorRef = useRef<HTMLDivElement>(null);
  // 最初の中身を固定する。以後、React はこの div の中を書き換えない
  const [initial] = useState(() => ({ __html: initialHtml }));
  const [isBold, setIsBold] = useState(false);

  // キャレットや選択範囲が太字の中にあるかを、ボタンの表示に写す
  const updateBold = useCallback(() => {
    const editor = editorRef.current;
    const sel = document.getSelection();
    const inside = !!editor && !!sel && sel.rangeCount > 0 && editor.contains(sel.anchorNode);
    setIsBold(inside && document.queryCommandState('bold') === true);
  }, []);

  useEffect(() => {
    document.addEventListener('selectionchange', updateBold);
    return () => document.removeEventListener('selectionchange', updateBold);
  }, [updateBold]);

  const toggleBold = () => {
    document.execCommand('bold');
    updateBold();
  };

  // 貼り付けは書式を捨てて、文字だけを入れる
  const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
    e.preventDefault();
    const text = e.clipboardData.getData('text/plain');
    document.execCommand('insertText', false, text);
  };

  return (
    <div className="editor">
      <div role="toolbar" aria-label="書式" className="toolbar">
        <button
          type="button"
          aria-label="太字"
          aria-pressed={isBold}
          // 押してもフォーカスと選択範囲を入力欄に残す
          onMouseDown={(e) => e.preventDefault()}
          onClick={toggleBold}
        >
          <b>B</b>
        </button>
      </div>
      <div
        ref={editorRef}
        className="body"
        contentEditable
        role="textbox"
        aria-multiline="true"
        aria-label="本文"
        dangerouslySetInnerHTML={initial}
        onInput={(e) => {
          onChange(e.currentTarget.innerHTML);
          updateBold();
        }}
        onPaste={handlePaste}
      />
    </div>
  );
}

使う側では、initialHtml に最初の中身を渡し、onChange で受け取った HTML を state に入れます。検証では、猫は<b>よく眠る</b>動物です。 を最初の中身にして、受け取った HTML を入力欄の下に <pre> で表示しました。

execCommand('bold') で太字を切り替える

execCommand('bold') は、MDN の execCommand() の説明によると、選択範囲かキャレットの位置で太字をオンとオフに切り替えるコマンドです。どの文字が太字かを自分で調べて、要素で包んだり外したりする処理は書いていません。

検証環境で、選択範囲を変えながら B ボタンを押しました。各行は、操作のあとの入力欄の HTML、選択している文字、フォーカスのある場所、ボタンの aria-pressed、入力欄の下に出した HTML です。

text
「猫」を選択
  → 本文: 猫は<b>よく眠る</b>動物です。 | 選択:「猫」 | フォーカス: 本文 | aria-pressed=false | 下の表示: 猫は<b>よく眠る</b>動物です。
太字ボタンをクリック
  → 本文: <b>猫</b>は<b>よく眠る</b>動物です。 | 選択:「猫」 | フォーカス: 本文 | aria-pressed=true | 下の表示: <b>猫</b>は<b>よく眠る</b>動物です。
End キーで行末へ移り、「よ」と入力
  → 本文: <b>猫</b>は<b>よく眠る</b>動物です。よ | 選択:「」 | フォーカス: 本文 | aria-pressed=false | 下の表示: <b>猫</b>は<b>よく眠る</b>動物です。よ
「よく眠る」(もともと太字)を選択
  → 本文: <b>猫</b>は<b>よく眠る</b>動物です。よ | 選択:「よく眠る」 | フォーカス: 本文 | aria-pressed=true | 下の表示: <b>猫</b>は<b>よく眠る</b>動物です。よ
太字ボタンをクリック
  → 本文: <b>猫</b>はよく眠る動物です。よ | 選択:「よく眠る」 | フォーカス: 本文 | aria-pressed=false | 下の表示: <b>猫</b>はよく眠る動物です。よ
「猫は」(「猫」だけ太字)を選択
  → 本文: <b>猫</b>はよく眠る動物です。よ | 選択:「猫は」 | フォーカス: 本文 | aria-pressed=false | 下の表示: <b>猫</b>はよく眠る動物です。よ
太字ボタンをクリック
  → 本文: <b>猫は</b>よく眠る動物です。よ | 選択:「猫は」 | フォーカス: 本文 | aria-pressed=true | 下の表示: <b>猫は</b>よく眠る動物です。よ

Chromium では、太字は <b> 要素で入りました。太字の部分を選んで押すと <b> が外れ、選択範囲の一部だけが太字のときは、全体が太字になりました。ボタンを押したあとも、下の表示は新しい HTML に変わっています。execCommand の変更でも入力欄の input イベントが起き、onInput から onChange が呼ばれたためです。

何も選ばずに、キャレットだけを置いて押すこともできます。文の最後にキャレットを置いて B ボタンを押し、「とても」と入力すると、<b>とても</b> が入りました。もう一度押してから「かわいい」と入力すると、こちらは太字になりませんでした。

text
太字ボタンをクリック
  → 本文: 猫は<b>よく眠る</b>動物です。 | 選択:「」 | フォーカス: 本文 | aria-pressed=true | 下の表示: 猫は<b>よく眠る</b>動物です。
「とても」と入力
  → 本文: 猫は<b>よく眠る</b>動物です。<b>とても</b> | 選択:「」 | フォーカス: 本文 | aria-pressed=true | 下の表示: 猫は<b>よく眠る</b>動物です。<b>とても</b>
もう一度太字ボタンをクリックし、「かわいい」と入力
  → 本文: 猫は<b>よく眠る</b>動物です。<b>とても</b>かわいい | 選択:「」 | フォーカス: 本文 | aria-pressed=false | 下の表示: 猫は<b>よく眠る</b>動物です。<b>とても</b>かわいい
注意

MDN では、execCommand() は非推奨(deprecated)で、標準の仕様にもないとされています。そのうえで、DOM を直接書き換える方法と違って編集の履歴(元に戻す履歴)が残るので、代わりのない使い方では使ってよい、ただし複数のブラウザで動きを確かめること、と書かれています。筆者が確かめたのは Chromium だけです。

元に戻す履歴が残ることは、検証環境でも確かめました。B ボタンで「猫」を太字にしてから Ctrl+Z キーを押すと、<b> が外れて元の HTML に戻りました。Ctrl+B キーは、ボタンの処理を何も書かなくても、Chromium がそのまま太字の切り替えとして扱いました。このときの input イベントの inputType は formatBold で、Ctrl+Z キーのときは historyUndo でした。B ボタンで太字にしたときも formatBold でした。

text
「動物」を選択
  → 本文: 猫は<b>よく眠る</b>動物です。 | 選択:「動物」 | フォーカス: 本文 | aria-pressed=false | 下の表示: 猫は<b>よく眠る</b>動物です。
Ctrl+B を押す
  → 本文: 猫は<b>よく眠る動物</b>です。 | 選択:「動物」 | フォーカス: 本文 | aria-pressed=true | 下の表示: 猫は<b>よく眠る動物</b>です。
  (input: formatBold)
Ctrl+Z を押す
  → 本文: 猫は<b>よく眠る</b>動物です。 | 選択:「動物」 | フォーカス: 本文 | aria-pressed=false | 下の表示: 猫は<b>よく眠る</b>動物です。
  (input: historyUndo)
「猫」を選んで太字ボタンをクリック
  → 本文: <b>猫</b>は<b>よく眠る</b>動物です。 | 選択:「猫」 | フォーカス: 本文 | aria-pressed=true | 下の表示: <b>猫</b>は<b>よく眠る</b>動物です。
  (input: formatBold)
Ctrl+Z を押す
  → 本文: 猫は<b>よく眠る</b>動物です。 | 選択:「猫」 | フォーカス: 本文 | aria-pressed=false | 下の表示: 猫は<b>よく眠る</b>動物です。
  (input: historyUndo)

(input: …)の行は、その操作のあいだに起きた input イベントの inputType です。

太字のほかに斜体や下線などのボタンを増やしていく場合は、React のリファレンスで、contentEditable を使って作るライブラリの例に挙がっている Lexical のような、リッチテキストの入力欄を作るためのライブラリを使う方法もあります。この記事では試していません。

入力欄の中身は React に描き直させない

contentEditable の中身は、ユーザーの入力で直接書き換わります。React のリファレンスにある contentEditable の説明では、contentEditable の要素に React の子要素を渡すと警告が出るとされています。ユーザーが編集したあとの中身を、React が更新できなくなるからです。

そこで、最初の中身だけを dangerouslySetInnerHTML で入れ、そのオブジェクトを useState で固定しました。こうすると、親の state が変わって描き直しが起きても、打った文字は続けて文の最後に入りました。前の節で、End キーで行末へ移って「よ」と入力したときや、「とても」と入力したときがその例です。

比べるために、中身を state で持ち、入力のたびに dangerouslySetInnerHTML={{ __html: html }} で描き直させる版も作りました。文の最後にキャレットを置き、「ね」「こ」「だ」と1文字ずつ入力しました。最後に、キャレットの位置を本文の先頭からの文字数で出しています。

text
本文をクリックし、End キーで行末へ
  → 本文: 猫は<b>よく眠る</b>動物です。 | 選択:「」 | フォーカス: 本文 | 下の表示: 猫は<b>よく眠る</b>動物です。
「ね」と入力
  → 本文: 猫は<b>よく眠る</b>動物です。ね | 選択:「」 | フォーカス: 本文 | 下の表示: 猫は<b>よく眠る</b>動物です。ね
続けて「こ」と入力
  → 本文: こ猫は<b>よく眠る</b>動物です。ね | 選択:「」 | フォーカス: 本文 | 下の表示: こ猫は<b>よく眠る</b>動物です。ね
続けて「だ」と入力
  → 本文: だこ猫は<b>よく眠る</b>動物です。ね | 選択:「」 | フォーカス: 本文 | 下の表示: だこ猫は<b>よく眠る</b>動物です。ね
  キャレットの位置: 先頭から 0 文字目の後ろ(本文は 14 文字)

1文字目は文の最後に入りましたが、2文字目からは先頭に入りました。「だ」を入力した直後も、キャレットは「だ」の後ろではなく、本文の先頭にありました。state が変わって React が中身を入れ直すたびに、キャレットが先頭に戻ったと考えられます。本番用のビルドでも開発用のビルドでも結果は同じで、ページのエラーや React の警告は1件も出ませんでした。動かしてみないと気づきにくい失敗です。

親に渡した HTML を、別の画面で dangerouslySetInnerHTML を使って表示するときも注意が要ります。React のリファレンスでは、完全に信頼できる出どころの HTML でなければ、XSS の脆弱性を作りやすいとされています。この記事では、保存した HTML を表示する側の処理は試していません。

ボタンを押してもフォーカスを入力欄に残す

B ボタンの onMouseDown で preventDefault() すると、ボタンを押してもフォーカスは入力欄に残ります。比べるために、preventDefault() しない版も作りました。この版は、太字の切り替えに必要なところだけを書いた、比べる用のコンポーネントです。どちらも「猫」を選んでボタンを押し、入力欄の blur の回数と、click の処理が始まったときにフォーカスがどこにあったかを数えました。

text
[preventDefault する版]
「猫」を選んで太字ボタンをクリック (本文の blur: 0回、click の処理が始まったときのフォーカス: 本文)
  → 本文: <b>猫</b>は<b>よく眠る</b>動物です。 | 選択:「猫」 | フォーカス: 本文 | aria-pressed=true | 下の表示: <b>猫</b>は<b>よく眠る</b>動物です。
[preventDefault しない版]
「猫」を選んで太字ボタンをクリック (本文の blur: 1回、click の処理が始まったときのフォーカス: 太字ボタン)
  → 本文: <b>猫</b>は<b>よく眠る</b>動物です。 | 選択:「猫」 | フォーカス: 本文 | 下の表示: <b>猫</b>は<b>よく眠る</b>動物です。

preventDefault() しない版では、マウスのボタンを押した時点でフォーカスが B ボタンに移り、入力欄の blur が1回起きました。太字にはなり、最後はフォーカスも入力欄に戻っています。ただ、click の処理が始まった時点では、フォーカスはボタンにありました。入力欄に戻っていたのは、click の処理で execCommand() を呼んだあとです。入力欄の onBlur で保存したり、ツールバーを隠したりしていると、ボタンを押すたびにその処理が動きます。preventDefault() する版では、blur は一度も起きませんでした。

ボタンを押したときにフォーカスが移るかどうかは、ブラウザや OS によって違います。MDN の button 要素の説明によると、多くのブラウザはクリックしたボタンにフォーカスを移しますが、Safari は移しません。preventDefault() しておけば、Safari でもほかのブラウザでも、入力欄からフォーカスが離れないと考えられます。Safari では試していません。

ボタンの押された状態を aria-pressed で出す

B ボタンは、キャレットや選択範囲が太字の中にあるあいだ、押された状態で表示します。MDN の aria-pressed の説明によると、ボタンに aria-pressed を付けると、オンとオフを切り替えるトグルボタンになります。見た目は、CSS の button[aria-pressed=true] で水色の背景にしました。

太字の中にいるかどうかは、document.queryCommandState('bold') で調べます。これも execCommand() と同じく非推奨のメソッドです。MDN では、状態が分からないときは null を返すことがあるとされているので、=== true で比べています。キャレットが入力欄の外にあるときに、ほかの場所の状態を拾わないように、選択範囲が入力欄の中にあるかも確かめています。

調べ直すタイミングは2つです。1つは、キャレットや選択範囲が動いたときです。document の selectionchange イベントで受け取ります。もう1つは、入力欄の中身が変わったときです。Ctrl+B キーのように、選んでいる文字が変わらない操作でも表示が追いつくように、onInput でも調べ直しています。上の Ctrl+B キーの結果では、aria-pressed が true に変わりました。onInput で調べ直さない版は試していないので、selectionchange だけで足りるかどうかは確かめていません。

Playwright で書き出したアクセシビリティツリーでは、ツールバーとボタンが次のように出ました。「よく眠る」を選んだ状態です。

text
- main:
  - toolbar "書式":
    - button "太字" [pressed]: B
  - textbox "本文"
  - text: 猫は<b>よく眠る</b>動物です。

貼り付けた文字の書式を外す

Chromium では、contentEditable の入力欄に貼り付けると、HTML 形式でクリップボードに入っている文字は、色や文字の大きさの付いた HTML のまま入ります。検証では、クリップボードに赤い 40px の「子猫」の HTML と、ただの文字の「子猫」を入れて、「動物」を選んだ状態で Ctrl+V キーを押しました。

text
onPaste で文字だけを入れる版:「動物」を選び、赤い 40px の「子猫」を貼り付ける
  → 本文: 猫は<b>よく眠る</b>子猫です。 | 選択:「」 | フォーカス: 本文 | aria-pressed=false | 下の表示: 猫は<b>よく眠る</b>子猫です。
  続けて Ctrl+Z を押す
  → 本文: 猫は<b>よく眠る</b>動物です。 | 選択:「動物」 | フォーカス: 本文 | aria-pressed=false | 下の表示: 猫は<b>よく眠る</b>動物です。
onPaste がない版:「動物」を選び、赤い 40px の「子猫」を貼り付ける
  → 本文: 猫は<b>よく眠る</b><span style="color: red; font-size: 40px">子猫</span>です。 | 選択:「」 | フォーカス: 本文 | 下の表示: 猫は<b>よく眠る</b><span style="color: red; font-size: 40px">子猫</span>です。

onPaste がない版では、style の付いた span がそのまま入りました。MDN の paste イベントの説明にあるとおり、貼り付けの中身を変えるには、preventDefault() で元の動きを止めてから、自分で入れます。handlePaste では、clipboardData.getData('text/plain') で文字だけを取り出し、execCommand('insertText') で入れています。こうすると、貼り付けも元に戻す履歴に残ります。検証環境では、貼り付けたあとに Ctrl+Z キーを1回押すと、元の「動物」に戻りました。

​Posted by 86