12k
All articles

ブラウザ内編集と contentEditable

ブラウザのcontenteditable編集: インライン文字入力を有効化し、inputイベントを取得、execCommandの制限とXSS対策を確認。

OpenReplay Team
OpenReplay Team
ブラウザ内編集と contentEditable

HTML要素に contenteditable 属性を追加するだけで、その要素はその場で編集可能になります。フォームコントロールも、ライブラリも、依存関係も一切不要です。

見出しを一つ変更するためだけにフォーム全体を構築した経験があるなら、この属性を初めて試したとき、まるでチートコードを使っているような感覚を覚えるでしょう。

ブラウザは対象要素を編集ホストに変換し、カーソルを配置して、ユーザーがレンダリングされたDOMに直接入力できるようにします。これにより、contenteditable は編集可能な見出し、クリックして編集するフィールド、または軽量なメモ欄を最速で実装する手段となっています。一方で、属性リファレンスには記載されていない落とし穴も存在します。ネイティブの change イベントがない、生成されるマークアップがブラウザによって異なる、旧来のフォーマットAPIが非推奨になっている、そしてレンダリング結果を他のユーザーに返す処理はXSSの典型的な脆弱点になり得る、といった問題です。本記事では、この属性の有効化、編集内容の正しいキャプチャと永続化、エッジケースへの対処、そして他の手段を検討すべき判断基準について解説します。

重要なポイント

  • contenteditable 属性は3つの値を取ります。true(または空文字列)は要素を編集可能にし、false は無効化し、plaintext-only はリッチテキストフォーマットを除去しながら生テキストの編集を許可します。
  • contentEditable にはネイティブの change イベントがありません。編集ホストへのあらゆる変更に対して発火する input イベントを使用してください。
  • 太字、イタリック、リンクのための document.execCommand() は非推奨かつ非標準です。本格的なリッチテキスト処理には、Selection APIとRange APIを beforeinput/input と組み合わせて使用するか、専用のエディタライブラリを採用してください。
  • ユーザーが入力した contenteditable のHTMLは、サニタイズせずにページに書き戻さないでください。DOMPurifyを使用するか、対応ブラウザではブラウザネイティブの setHTML() をDOMPurifyのフォールバックと組み合わせて使用してください。
  • contenteditable="plaintext-only" は、2025年3月リリースのFirefox 136での実装により、Chromiumおよび WebKitの既存サポートと合わせてクロスブラウザ対応となりました。

有効化の方法:contenteditable属性

contenteditable グローバル属性は3つの値を取り、適切な値を選択することが最も重要です。true(または空文字列)は要素を編集可能にし、false は無効化し、plaintext-only はリッチテキストフォーマットを無効にしながら生テキストを編集可能にします。MDNのcontenteditable リファレンスによると、これはboolean属性ではなく列挙型属性です。値が省略または無効な場合は、親要素の編集可能状態を継承します。

最もシンプルな記述例:

<h1 contenteditable="true">Edit this heading</h1>

テキストのみのフィールド(変更可能なタイトル、タグ入力、単一行のメモなど)には plaintext-only を優先してください。これにより、貼り付け時のリッチフォーマットをソースの段階でブロックできます。contenteditable="true" の要素に貼り付けたコンテンツはすべてのフォーマットが保持されますが、contenteditable="plaintext-only" の要素に貼り付けたコンテンツはすべてのフォーマットが除去されます。

JavaScriptからは contentEditable プロパティ(キャメルケース)を通じて編集状態を切り替えられます:

const el = document.querySelector('#note');
el.contentEditable = 'plaintext-only'; // または 'true' / 'false'

contenteditable の編集内容をキャプチャして永続化するには?

contentEditable にはネイティブの change イベントがありません。編集内容をキャプチャするには、編集ホストへのあらゆる変更に対して発火する input イベントを使用してください。古いチュートリアルで keypresskeyup を使用しているのは最もよくある誤りで、貼り付け操作、ドラッグ&ドロップ、IME入力を取りこぼしてしまいます。フォーマットを保持する必要がある場合は element.innerHTML を、プレーンテキストが必要な場合は element.textContent を読み取り、永続化してロード時に復元してください。

const el = document.querySelector('#note');

// ロード時に復元
el.textContent = localStorage.getItem('note') ?? '';

// 編集のたびにデバウンスして永続化
let t;
el.addEventListener('input', () => {
  clearTimeout(t);
  t = setTimeout(() => {
    localStorage.setItem('note', el.textContent);
    // または: fetch('/api/note', { method: 'POST', body: el.textContent })
  }, 400);
});

リッチマークアップを保存する場合は textContentinnerHTML に置き換えてください。ただし、その選択がメモ欄を攻撃対象にする可能性があるため、先にセキュリティのセクションをお読みください。より細かい制御が必要な場合は、DOMが変更される前に発火し、編集内容の検査やキャンセルが可能な beforeinput イベントを使用してください。このイベントは contenteditable 要素と designMode 状態の要素の両方に適用されます。

落とし穴

ここが contenteditable の評判が分かれるポイントです。本番環境では3つの問題が頻繁に発生します。

不整合で乱雑なマークアップ。 contenteditable 領域が生成するHTMLはブラウザによって異なるため、保存された出力が期待通りにクリーンであることはほとんどありません。Scott O’Haraが文書化したように、Safariは歴史的に改行を <div> 要素でラップし、Firefoxは <br> 要素を挿入してきました。また、<div><p> の無効な子要素であるため、段落を編集可能にした場合にレンダリングの不具合が生じます。本番環境でよくある失敗パターンは、ユーザーがWordやGoogle Docsから貼り付けを行い、<span> ラッパーとインラインスタイルが混在した状態になることです。このような編集セッションのセッションリプレイを活用することで、破損したデータベースのレコードから逆算するのではなく、実際に不正な出力が生成される様子を観察できます。実践的な対処法は、plaintext-only を優先するか、input/paste イベントでサニタイズすることです。

execCommand は非推奨。 太字、イタリック、リンクフォーマットに長く使われてきた document.execCommand() は、MDNによると非推奨かつ非標準となっています。完全な代替手段が存在しないため、レガシーコードには残り続けていますが、新しいリッチテキスト機能の実装には使用しないでください。MDNでは、この関数が独自にアンドゥバッファを保持していることも指摘しています。新しい実装では、Selection APIと Range APIを beforeinput/input と組み合わせて使用してください。ただし、これらは低レベルのプリミティブであり、ブラウザ間でRangeの動作が異なる点に注意が必要です。非自明な要件には、専用のエディタフレームワークを使用することを強くお勧めします。

XSS。 ユーザーが入力した contenteditable のHTMLは、サニタイズせずに他のユーザーに対してレンダリングしてはなりません。サニタイズされていない innerHTML への書き込みは、直接的なインジェクション攻撃の経路となります。DOMPurify(現在も活発にメンテナンスされている3.x系)でサニタイズするか、ブラウザのネイティブSanitizer APIが利用可能な場合はDOMPurifyのフォールバックと組み合わせて使用してください:

function safeRender(el, html) {
  if ('setHTML' in Element.prototype) {
    el.setHTML(html);              // ネイティブ、スクリプト/ハンドラを除去
  } else {
    el.innerHTML = DOMPurify.sanitize(html);
  }
}

ネイティブの実装は比較的新しいものです。2026年2月24日にリリースされたFirefox 148では、HTML Sanitizer APIのサポートが追加され、XSS攻撃のリスクを低減するためにDOMへの挿入前にHTMLをサニタイズする setHTML() などのメソッドが実装されました。ChromeとEdgeも対応していますが、setHTML() はまだBaselineに達していないため、フォールバックは維持してください。OpenReplayのHTML Sanitizer APIの概要では、その仕組みを詳しく解説しています。

アクセシビリティ

編集可能な領域は、実際のコントロールとして動作する必要があります。キーボードユーザーがカーソル位置を確認できるよう、フォーカス時の視覚的スタイリングを追加し、領域にラベルを付けてください。contenteditable 要素にはアクセシブルな名前が暗黙的に設定されないため、aria-label または関連付けられたラベルを追加してください:

[contenteditable]:focus {
  outline: 2px solid #2563eb;
  outline-offset: 2px;
}
<div contenteditable="plaintext-only" aria-label="Note body" role="textbox"></div>

編集可能な要素はフォーカス可能であり、順次キーボードナビゲーションに参加しますが、ネストされた編集可能要素はデフォルトではタブオーダーに追加されません。UIが変更される際はフォーカスを適切に管理してください。たとえば、ユーザーがボタンを操作した後にそのボタンが消える場合(アンドゥコントロールがリドゥに切り替わる場合など)、キーボードユーザーがフォーカスを失わないよう、.focus() を使って表示されている要素にフォーカスを戻してください。この点はScott O’Haraのアンドゥ/リドゥの実装でも指摘されています。

contenteditable を使うべき場合と使わない場合

contenteditable は軽量なインライン編集、すなわち編集可能な見出し、クリックして編集するフィールド、ライブコード/プレビューのような用途に適しています。信頼性が高く予測可能な入力が必要な場合はプレーンなフォームコントロールを、クリーンな出力を伴う構造化されたリッチテキストが必要な場合は専用のエディタフレームワークを選択してください。

要件最適なツール
単一/複数行のプレーンテキスト、フォーム送信<input> / <textarea>
表示コンテンツのインライン編集(プレーンテキスト)contenteditable="plaintext-only"
ブラウザ内のライブコード/プレビューcontenteditable
信頼性の高いリッチテキスト、構造化/コラボレーティブコンテンツエディタライブラリ(ProseMirror、Lexical、Tiptap)

この判断は出力の予測可能性にかかっています。<textarea> はクリーンな文字列と実際の change イベントを提供しますが、contenteditable が提供するのはレンダリングされたHTMLであり、その正確な形式はブラウザとユーザーが貼り付けた内容によって異なります。成熟したエディタライブラリが存在するのは、まさにその出力を制御すること(正規化されたマークアップ、ドキュメントモデル、アンドゥ履歴、サニタイズ)が、すでに誰かが解決した大きな課題だからです。

編集領域が小さく、出力がプレーンテキストまたは一時的なものである場合は contenteditable を選択してください。信頼性の高い構造化されたHTMLが必要になった瞬間に、plaintext-only とサニタイズで入力を厳しく制限するか、その目的のために構築されたツールに任せてください。

よくある質問

ユーザーが編集を終了したとき、contenteditable は change イベントを発火しますか?

いいえ。contenteditable 要素にはネイティブの change イベントがありません。そのため、keypress や keyup を使用する古いチュートリアルでは、貼り付け操作、ドラッグ&ドロップ、IME入力を取りこぼしてしまいます。代わりに input イベントを使用してください。このイベントは、変更がどのような方法で行われたかに関わらず、編集ホストへのあらゆる変更に対して発火します。DOMが変更される前に編集内容を検査またはキャンセルする必要がある場合は、contenteditable 要素にも適用される beforeinput イベントを使用してください。

複数行のテキストフィールドには contenteditable と textarea のどちらを使うべきですか?

送信または保存を予定しているプレーンテキストには textarea を使用してください。クリーンな文字列を返し、実際の change イベントを発火するためです。contenteditable は、別のフォームコントロールではなく、表示されているコンテンツをその場でインライン編集する必要がある場合にのみ使用してください。テキストのみのフィールドであれば、contenteditable='plaintext-only' が最も近い選択肢です。貼り付け時のリッチフォーマットをソースの段階で除去しながら、レンダリングされたコンテンツを直接編集できます。

太字やイタリックのフォーマットに execCommand を使用しても安全ですか?

新しいリッチテキスト機能の実装に execCommand を使用しないでください。MDNはこれを非推奨かつ非標準としてマークしています。完全な代替手段が存在せず、ブラウザのアンドゥバッファを独自に保持しているため、レガシーコードには残り続けています。新しい実装では、Selection APIとRange APIを beforeinput および input イベントと組み合わせて使用してください。ただし、これらは低レベルのプリミティブであり、ブラウザ間でRangeの動作が異なります。非自明な要件には、専用のエディタライブラリを使用してください。

contenteditable の plaintext-only は Firefox で動作しますか?

はい。plaintext-only の値は2025年3月リリースのFirefox 136で実装され、ChromiumおよびWebKitの既存サポートと合わせてクロスブラウザ対応となりました。この値はリッチテキストフォーマットを無効にしながら生テキストを編集可能にするため、plaintext-only 要素に貼り付けたコンテンツはすべてのフォーマットが除去されます。これにより、後からサニタイズするのではなく、貼り付け時の乱雑なマークアップをソースの段階でブロックできるため、テキストのみのフィールドに最適な選択肢となっています。

Open-source session replay

Gain control over your UX

See how users are using your site as if you were sitting next to them, learn and iterate faster with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.