HTMLではある要素に対してcontenteditable属性をtrueに設定することでinput要素やtextarea要素を利用することなくユーザがブラウザ上で要素内の文字列を直接編集することができます。

Reactでもcontenteditable属性を利用することができ、react-contenteditableライブラリというものも存在します。本文書ではライブラリを利用することなくReactでのcontenteditableの動作確認をおこないます。動作確認はFunctionalコンポーネント上で設定を行い、React HookのuseStateとuseRefを利用します。

contentEditable属性はリッチテキストエディターからX(旧Twitter)のツイートを入力するツイートエリアまで幅広場所で利用されています。リッチテキストエディターなどのライブラリを利用する場合はライブラリ内で設定が行われているため意識することはありませんがユーザがコンテンツを作成するアプリケーションを作成する場合には必須な知識となります。

contenteditable属性とは

contenteditable属性が何かわからない人もいるかと思いますのでまずHTMLを利用してcontenteditable属性を設定することで何ができるようになるのか確認を行います。

例えば下記のようにdiv要素にcontenteditable属性を設定するとブラウザ上で直接表示されている文字列を書き換えることができるようになります。


<!DOCTYPE html>
<html>
<head>
	<title>contenteditable属性</title>
</head>
<body>
	<div contenteditable="true">
		この文章は書き換えることができます。
	</div>
</body>
</html>

ブラウザで確認すると通常の表示と違いはなく表示されている文字列を書き換えれるかどうかは画面を見ただけではわかりません。

contenteditable属性を設定
contenteditable属性を設定

contenteditable属性を設定した要素をクリックするとChromeでは要素にボーダーが表示され、ボーダー内の文字をそのまま更新することができます。

文字列を直接ブラウザ上で追加
文字列を直接ブラウザ上で追加

上記のようにcontenteditable属性を設定するだけで要素内の文字列の編集が可能となります。

HTMLでのcontentediable属性がどのようなものか理解できたと思うので、Reactを使いcontentediableを利用する方法を確認していきます。

Reactでcontentediable属性を利用する

ContentEditableコンポーネントの作成

Reactプロジェクトのsrcフォルダの下にcomponentsフォルダを作成し、ContentEditable.jsファイルを作成します。

ファイル作成後、下記のコードを記述します。


const ContentEditable = () => {

  return (
    <div
      contentEditable
      dangerouslySetInnerHTML={{ __html: "この文章は書き換えることができます。" }}
    />
  );
};

export default ContentEditable

div要素にcontentEdiableを設定するとcontentediable属性がtrueに設定されます。div要素の中に文字列を挿入するためにJavaScriptではinnerHTMLを利用しますが、ReactではdangerouslySetInnerHTMLを利用することでdiv要素内に指定した文字列を挿入することができます。dangerouslySetInnerHTMLの引数には__htmlキーを持つオブジェクトを指定し、値にはブラウザ上に表示させたい内容を記述します。

App.jsからContentEdiableをimport

作成したContentEdiableコンポーネントをApp.jsファイルでimportします。


import ContentEditable from './Components/ContentEditable'

export default function App() {

  return (
    <div style={{'margin':'2em'}}>
        <ContentEditable />
    </div>
  );
}

先ほどHTMLのみで確認したように”この文章は書き換えることができます。”が表示されます。

dangerouslySetInnerHTMLで設定した内容表示
dangerouslySetInnerHTMLで設定した内容表示

これだけdの設定で表示されている文字列を直接編集することができるようになります。

ブラウザ上で更新
ブラウザ上で更新

編集することは可能ですが、この値を保持する仕組みがないためuseStateを利用して編集した値を変数に保存します。

useStateを追加

編集した文字列を保持できるようにuseStateを利用します。useStateで変数textを定義し初期値に”この文章は書き換えることができます。”を設定します。textはpropsのvalueを通してContentEditableコンポーネントに渡します。


import { useState } from "react";
import ContentEditable from './Components/ContentEditable'

export default function App() {

  const [text, setText] = useState("この文章は書き換えることができます。");

  return (
    <div style={{'margin':'2em'}}>
        <ContentEditable value={text} />
    </div>
  );
}

propsで渡されたvalueをContentEditableコンポーネントで受け取り、dangerouslySetInnerHTMLに直接設定した文字列を渡されたpropsのvalueに変更します。ブラウザでは下記のように表示されます。textの初期値を変更するとブラウザ上に表示される文字列も変わります。

dangerouslySetInnerHTMLで設定した内容表示
propsのvalueで渡された値を表示

ブラウザ上のdiv要素内で編集した内容が定義したtextに反映させる必要があるためtextに反映されているかどうか確認できるようにApp.jsを更新します。


<div style={{'margin':'2em'}}>
    <ContentEditable value={text} />
    <div>
      {text}
    </div>
</div>

{text}の追加によりtextの内容はブラウザ上に表示されます。これだけの設定ではcontenteditableを設定したdiv要素の文字列は更新することができますが、更新した値はtextには反映されません。次は編集した内容がtextに反映されるように設定を行っていきます。

divを更新してもtextに反映されない
divを更新してもtextに反映されない

編集した内容を反映

useStateで定義したtextの内容を更新するためにsetTextメソッドを利用します。propsのonChangeを追加し、setTextをpropsでContentEditableコンポーネントに渡します。


<ContentEditable value={text} onChange={setText} />

ContentEditableコンポーネントではpropsで受け取ったonChangeを文字列の更新毎に実行するためonInputイベントを利用します。onInputイベントではhandleInputメソッドを設定し、イベントに保存されているe.target.innerHTMLを利用してdiv要素内の文字列を取得しています。

input要素の場合は入力毎に関数を実行するためonChageイベントを設定しますがdiv要素でonChangeイベントを設定してもイベントは発火しません。
fukidashi

const ContentEditable = ({value, onChange}) => {

  const handleInput = (e) => {
    onChange(e.target.innerHTML)
  }

  return (
    <div
      contentEditable
      onInput={handleInput}
      dangerouslySetInnerHTML={{ __html: value}}
    />
  );
};

export default ContentEditable

文字列の更新を行おうとすると入力した文字の1つ目はその場所に表示されますが続けて入力するとテキストの入力場所を示すcaret(キャレット)が先頭に戻るため連続で文字を入力すると先頭に追加されていきます。例えばabcと入力するとcbaと入力されます。

下記では”書き換え”の前にabcを入れようとしたところ、1文字目は指定した場所に追加できますがその後の文字列は先頭に入力されます。

caretが先頭にいくため正しく入力できない
caretが先頭にいくため正しく入力できない
文字を1つ入力する毎にdiv要素からフォーカスを外すことでabcと入力することも可能です。
fukidashi

文字の入力はうまくいきませんでしたが、setTextを利用して文字列の更新後の情報を設定しているのでdiv要素の文字列とtextで表示される内容が同じ内容になっていることは確認できます。

useRefの利用

この問題を解決するためにuseRefを利用します。useRefはDOMノードに直接アクセスする際にも利用することができますが、useStateと同様に値を保持することができます。useRefにtextの初期値を渡すことで文字列を更新できるようになります。


import {useRef} from 'react'
const ContentEditable = ({value, onChange}) => {
  const defalutValue = useRef(value);

  const handleInput = (e) => {
    onChange(e.target.innerHTML)
  }

  return (
    <div
      contentEditable
      onInput={handleInput}
      dangerouslySetInnerHTML={{ __html: defalutValue.current}}
    />
  );
};

export default ContentEditable

useRefではpropsのvalueを初期値として設定しています。useRefでは設定した値はcurrentプロパティの中に保存されているので、dangerouslySetInnerHTMLではdefalutValue.currentを設定しています。

useRefを設定後は、文字列を更新するとキャレットが先頭に移動することなく更新することができます。divの更新した内容もtextに反映されていることも確認できます。

useRef設定後文字列を正常に更新できる
useRef設定後文字列を正常に更新できる

useState, useRefを利用することでReact上でcontentediable属性がtrueのdiv要素の文字列を編集できる上、編集した値を保存する仕組みを作ることができました。

onBlurイベント

div要素からカーソルを外した場合にサーバに更新内容を送信する等の何か別の処理が行えるようにonBlurイベントを設定してtextの内容が正しく表示されるか確認します。

App.jsにonBlurメソッドを追加し、contentEdiableコンポーネントでpropsに渡します。


import { useState } from "react";
import ContentEditable from './Components/ContentEditable'

export default function App() {

  const [text, setText] = useState("この文章は書き換えることができます。");

  const handleBlur = () => {
    console.log(text);
  }

  return (
    <div style={{'margin':'2em'}}>
        <ContentEditable value={text} onChange={setText} onBlur={handleBlur}/>
        <div>
          {text}
        </div>
    </div>
  );
}

ContentEditableコンポーネントではonBlurイベントを設定しカーソルが外れたらpropsで渡されたonBlur関数を実行します。


import {useRef} from 'react'
const ContentEditable = ({value, onChange, onBlur}) => {
  const defalutValue = useRef(value);

  const handleInput = (e) => {
    onChange(e.target.innerHTML)
  }

  return (
    <div
      contentEditable
      onInput={handleInput}
      onBlur={onBlur}
      dangerouslySetInnerHTML={{ __html: defalutValue.current}}
    />
  );
};

export default ContentEditable

div要素の文字列を編集し、カーソルをdiv要素から外すとonBlurイベントによりコンソールに更新した文字列の内容が表示されます。

onBlurの動作確認
onBlurの動作確認

複数のContentEditableを利用する

ContentEditableはコンポーネント化されているので複数のContentEditableを同時に利用することもできます。

useStateでnameを定義しています。


import { useState } from "react";
import ContentEditable from './Components/ContentEditable'

export default function App() {

  const [text, setText] = useState("この文章は書き換えることができます。");
  const [name, setName] = useState("John Doe");

  const handleTextBlur = () => {
    console.log(text);
  }

  const handleNameBlur = () => {
    console.log(name);
  }

  return (
    <div style={{'margin':'2em'}}>
        <ContentEditable value={text} onChange={setText} onBlur={handleTextBlur}/>
        <div>
          Text:{text}
        </div>
        <ContentEditable value={name} onChange={setName} onBlur={handleNameBlur}/>
        <div>
          Name:{name}
        </div>
    </div>
  );
}

設定後、ブラウザで確認すると以下のように表示されます。

複数のContentEditableコンポーネントを利用
複数のContentEditableコンポーネントを利用

それぞれはコンポーネント化されているので別々に更新することができます。

個別にdiv要素の文字列を更新
個別にdiv要素の文字列を更新

useStateのみではReact上でcontenteditableを正常に動作させることができませんでしたがuseRefを利用することでReact上でもcotenteditableを正常に動作させることができました。

もし本文書の設定では期待通りの動作にならない場合はreact-contenteditableを利用してください。