React ContentEditable属性で要素を編集

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属性を設定した要素をクリックすると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のみで確認したように”この文章は書き換えることができます。”が表示されます。

これだけ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の初期値を変更するとブラウザ上に表示される文字列も変わります。

ブラウザ上のdiv要素内で編集した内容が定義したtextに反映させる必要があるためtextに反映されているかどうか確認できるようにApp.jsを更新します。
<div style={{'margin':'2em'}}>
<ContentEditable value={text} />
<div>
{text}
</div>
</div>
{text}の追加によりtextの内容はブラウザ上に表示されます。これだけの設定ではcontenteditableを設定したdiv要素の文字列は更新することができますが、更新した値はtextには反映されません。次は編集した内容がtextに反映されるように設定を行っていきます。

編集した内容を反映
useStateで定義したtextの内容を更新するためにsetTextメソッドを利用します。propsのonChangeを追加し、setTextをpropsでContentEditableコンポーネントに渡します。
<ContentEditable value={text} onChange={setText} />
ContentEditableコンポーネントではpropsで受け取ったonChangeを文字列の更新毎に実行するためonInputイベントを利用します。onInputイベントではhandleInputメソッドを設定し、イベントに保存されているe.target.innerHTMLを利用してdiv要素内の文字列を取得しています。

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文字目は指定した場所に追加できますがその後の文字列は先頭に入力されます。


文字の入力はうまくいきませんでしたが、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に反映されていることも確認できます。

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イベントによりコンソールに更新した文字列の内容が表示されます。

複数の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>
);
}
設定後、ブラウザで確認すると以下のように表示されます。

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

useStateのみではReact上でcontenteditableを正常に動作させることができませんでしたがuseRefを利用することでReact上でもcotenteditableを正常に動作させることができました。
もし本文書の設定では期待通りの動作にならない場合はreact-contenteditableを利用してください。