画像キャプションのキーボードショートカット(Ctrl+C/A/X)を修正

basecamp/lexxy

Lexicalエディタの contenteditable ルート内に存在するキャプション用 <textarea> のキーボード・クリップボードイベントが上位にバブリングし、Lexicalに横取りされていた問題を修正しました。stopPropagation() の適用範囲を拡張することで、Ctrl+A/C/X が <textarea> 内のテキストに対して正しく機能するようになります。

背景

キャプション <textarea> は Lexical エディタの contenteditable ルート要素の内側に配置されていますが、Lexical のコンテンツモデルの外側に位置します。この構造が問題の根本原因です。<textarea> で発生したキーボードイベントやクリップボードイベントはDOMツリーを上位にバブリングし、Lexical のイベントハンドラに到達します。Lexical はこれらのイベントを自身のノード選択に対するコマンドとして解釈し、SELECT_ALL_COMMANDCUT_COMMANDCOPY_COMMAND を発火させていました。

結果として、キャプション入力中に Ctrl+A を押すとテキスト全選択ではなくエディタ全体の選択が発動し、Ctrl+X を押すとキャプションテキストではなく画像ノードそのものが削除され、Ctrl+C を押すとフォーカスが失われるという深刻な操作上のバグが発生していました。

既存の実装では stopPropagation() が Enterキーの処理ブランチ内にのみ存在し、他のキーイベントやクリップボードイベントには適用されていなかったことが直接の原因です。

技術的な変更

src/nodes/action_text_attachment_node.js において、イベント伝播の制御を2箇所で修正しました。

変更前(Enter キーのみ stopPropagation):

#handleCaptionInputKeydown(event) {
  if (event.key === "Enter") {
    event.preventDefault()
    event.stopPropagation()  // Enter の処理ブランチ内にのみ存在
    event.target.blur()
    // ...
  }
}

変更後(すべての keydown イベントに stopPropagation を適用):

#handleCaptionInputKeydown(event) {
  if (event.key === "Enter") {
    event.preventDefault()
    event.target.blur()
    // ...
  }

  // Stop all keydown events from bubbling to the Lexical root element.
  // The caption textarea is outside Lexical's content model and should
  // handle its own keyboard events natively (Ctrl+A, Ctrl+C, Ctrl+X, etc.).
  event.stopPropagation()
}

さらに、クリップボードイベントに対する伝播停止を <textarea> の初期化時に追加しています。

input.addEventListener("copy",  (event) => event.stopPropagation())
input.addEventListener("cut",   (event) => event.stopPropagation())
input.addEventListener("paste", (event) => event.stopPropagation())

keydown イベントのみを止めてもクリップボードイベント(copy/cut/paste)は独立して発生するため、両方を塞ぐ必要があります。

テストは test/browser/tests/attachments.test.js に3件のリグレッションテストとして追加されました。各テストは画像をアップロードしてキャプションにテキストを入力した後、ショートカット実行後の選択範囲・テキスト値・図の存在をPlaywrightで検証します。macOS/Linux 両プラットフォームに対応するため、モディファイアキーを process.platform === "darwin" ? "Meta" : "Control" で切り替えています。

設計判断

Lexical のコンテンツモデル外の要素に対する原則として、stopPropagation() の全面適用 が採用されました。

修正はイベントリスナーの追加のみであり、Lexical のコマンド優先度や内部APIには一切触れていません。これは Lexical のアーキテクチャへの最小侵襲な対処です。<textarea> が自身のイベントを自律的に処理し、外部(Lexical)への漏れを遮断するという責任の分離として整理できます。

この判断は .claude/skills/bugs-reproducer.md の知識ベースにも反映されており、「Lexical の contenteditable ルート内に配置した非Lexical要素は、keydowncopycutpaste の4イベントすべてに stopPropagation() を設定すること」というルールとして明文化されています。今後同様の要素を追加する際の設計指針となります。

まとめ

本修正は、Lexical の contenteditable ルートに埋め込まれた非Lexical要素のイベント管理を適切に封じ込めることで、標準的なテキスト編集操作を復元したものです。stopPropagation() の適用範囲を keydown 全体とクリップボードイベントに広げるという最小限の変更でありながら、その原則をドキュメント化することで将来の同種バグを予防する設計になっています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
e2d30912

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

「リード文→背景→技術的な変更→設計判断→まとめ」の構成が明確であり、総論→各論→結論の流れが理想的に実現されています。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```javascript:ファイルパス)およびGitHubのPRリンク記法([#820](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

イベントバブリングやLexicalのコンテンツモデルなど、専門的なトピックを前提としており、対象読者であるエンジニアに適した技術レベルで記述されています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションの構成が論理的であり、各パラグラフがトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が遵守されています。可読性が非常に高いです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内のすべてのコードブロックは、提供されたDiffの内容(変更前、変更後、追加箇所)を正確に反映しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「イベントバブリング」「stopPropagation」「contenteditable」などの技術用語が、文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

問題の根本原因(イベントのバブリング)と解決策(stopPropagationによる伝播停止)に関する技術的な説明が正確かつ論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張(バグの挙動、修正内容、テストの追加、設計原則の文書化など)は、PRのDescriptionやDiffの内容によって完全に裏付けられています。ハルシネーションは見られません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#820)やファイルパスが正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルはPRのタイトルを正確に反映しており、内容を的確に要約しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PR情報に含まれない外部知識(バージョンのサポート状況など)の追加はなく、提供された情報のみに基づいて記述されています。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

時間表現に誤りはなく、完了した変更として適切に記述されています。