フォーカス中のエディタへの値設定時、DOM選択状態の更新をスキップしないよう修正

basecamp/lexxy

Safariでフォーカス中のエディタに値を設定したとき、contenteditable内のカーソル位置が不整合な場所に置かれるバグを修正しました。フォーカス状態の有無に応じてDOM選択の更新戦略を切り替えることで、SafariとそれME外のブラウザの両方で正しい動作を実現しています。

背景

これまで value セッターは、エディタの更新オプションに常に SKIP_DOM_SELECTION_TAG を付与していました。このタグはLexicalにDOM側の選択状態(カーソル位置)の更新をスキップさせる指示で、値の差し替え時に余計なフォーカス移動を防ぐ意図で導入されていました。

しかしこの実装は、エディタがすでにフォーカスされている状態で値を設定したケースで問題を引き起こします。SafariはLexicalがDOMのノードを再構築した後、カーソルを不正な位置に残すことがあります。SKIP_DOM_SELECTION_TAGによってDOM選択の更新自体がスキップされているため、Safariが誤った位置に置いたカーソルを修正する機会がありませんでした。

この問題は #isContentFocused ゲッターの実装にも影響を受けていました。変更前は this.editorContentElement.contains(document.activeElement) でDOM上のアクティブ要素を確認していたため、Lexicalが内部的に管理するフォーカス状態と乖離するケースがありました。

技術的な変更

フォーカス状態の検出方法と value セッターの更新ロジックが、エディタのフォーカス状態に応じて分岐する実装に変更されました。

#isContentFocused の変更:

// 変更前
get #isContentFocused() {
  return !!this.editorContentElement && this.editorContentElement.contains(document.activeElement)
}

// 変更後
get #isContentFocused() {
  return !!this.editor && isEditorFocused(this.editor)
}

DOM上の activeElement ではなく、Lexicalが提供する isEditorFocused ユーティリティを使用することで、Lexicalの内部フォーカス状態と一致した判定ができるようになりました。

value セッターの変更:

// 変更前
set value(html) {
  this.editor.update(() => {
    $getRoot()
      .clear()
      .selectEnd()
      .insertNodes(this.#parseHtmlIntoLexicalNodes(html))

    this.#toggleEmptyStatus()
  }, { discrete: true, tag: SKIP_DOM_SELECTION_TAG })
}

// 変更後
set value(html) {
  const editorHasFocus = this.#isContentFocused

  this.editor.update(() => {
    if (editorHasFocus) {
      // Address Safari inconsistently placing the cursor in the contenteditable by forcing focus back onto the editor
      // Use direct `editor.focus` to bypass the pre-existing focus optimization and skip the callback
      $onUpdate(() => this.editor.focus())
    } else {
      $addUpdateTag(SKIP_DOM_SELECTION_TAG)
    }

    $getRoot()
      .clear()
      .selectEnd()
      .insertNodes(this.#parseHtmlIntoLexicalNodes(html))

    this.#toggleEmptyStatus()
  }, { discrete: true })
}

更新コールバックの外で editorHasFocus を事前にキャプチャしている点が重要です。editor.update() のコールバックはLexicalの更新サイクル内で非同期的に実行されるため、コールバック内でフォーカス状態を参照すると実行タイミングによって値が変わる可能性があります。

フォーカスが ある 場合は SKIP_DOM_SELECTION_TAG を付与せず、$onUpdate で更新完了後に editor.focus() を呼び出してフォーカスを強制的に再適用します。コメントに「bypass the pre-existing focus optimization and skip the callback」とあるとおり、Lexicalのフォーカス最適化を迂回してSafariのカーソルを正しい位置に戻す意図です。フォーカスが ない 場合は従来どおり $addUpdateTag(SKIP_DOM_SELECTION_TAG) で不要なDOM選択更新をスキップします。

合わせて $addUpdateTag$onUpdatelexical からの import に追加されています。

設計判断

更新オプションのグローバル指定から、フォーカス状態に応じた条件分岐へ という方針の転換が本PRの核心です。

変更前は editor.update() の第二引数で tag: SKIP_DOM_SELECTION_TAG を渡すことで全更新に一律適用していました。変更後はこのグローバル指定を廃止し、コールバック内で $addUpdateTag() を条件付きで呼び出す形に移行しています。これにより「フォーカスなし → 選択更新スキップ」「フォーカスあり → 選択更新を行い、さらにフォーカスを再適用」という明示的な二系統の制御が実現しています。

Safariの問題に対して「フォーカスを再適用する」というアプローチを採ったことも設計上の注目点です。DOMの再構築後にLexicalの editor.focus() を呼ぶことで、Safariが誤配置したカーソルをLexicalのフォーカスハンドリング経由でリセットしています。Safari固有の条件分岐をアプリケーションコードに持ち込まず、「フォーカス中の値更新後はフォーカスを再整合する」という汎用的なロジックとして表現している点は、将来の保守性を高める判断といえます。

まとめ

本PRは、SKIP_DOM_SELECTION_TAGの一律適用という単純な実装から、エディタのフォーカス状態を軸にDOM選択制御を条件分岐させる設計へ移行した変更です。Safariのカーソル不整合というブラウザ固有の問題を、Lexicalのフォーカス再適用APIを活用して汎用的に解決しており、フォーカスなしのケースでは従来の最適化が引き続き有効です。

記事メタデータ

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

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

「総論→各論→結論」の構成が記事全体、および各セクションで明確に適用されています。リード文、背景、技術詳細、設計判断、まとめの各要素が適切に配置され、非常に理解しやすい構造です。

カスタムMarkdown構文 ⚠ WARNING

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

ファイル名付きシンタックスハイライトは正しく使用されています。しかし、GitHubのPRリンク記法が `[PR #1011](URL)` となっており、ガイドラインで推奨される `[#1011](URL)` の形式と若干異なります。

対象読者への適合性 ✓ PASS

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

LexicalやDOM操作に関する専門知識を持つエンジニアを対象としており、冗長な説明がなく、技術レベルが適切です。

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

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

各セクション、各パラグラフが「総論→各論」の構造で書かれています。ほぼ全ての段落がトピックセンテンスで始まり、1段落1トピックが守られているため、非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロック(`#isContentFocused`と`value`セッターの変更)は、提供されたDiff情報と完全に一致しています。ファイルパスも正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`SKIP_DOM_SELECTION_TAG`, `isEditorFocused`, `$onUpdate`などのLexicalに関する技術用語が正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

Safariでのカーソル問題、`SKIP_DOM_SELECTION_TAG`の役割、`editor.update()`の非同期性など、技術的な説明はすべて正確で、DiffやPRの記述によって裏付けられています。

事実の突合 ✓ PASS

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

記事内の主張はすべてPRのDescriptionやDiff内のコードから裏付けが取れます。「設計判断」セクションの解説も、コードの変更意図を的確に分析したものであり、ハルシネーションは見られません。

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

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

PR番号「#1011」が正確に記載されています。その他の数値や固有名詞にも誤りはありません。

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

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

記事のタイトル「フォーカス中のエディタへの値設定時、DOM選択状態の更新をスキップしないよう修正」は、PRのタイトルと内容を正確に反映しています。

外部知識の正確性 ✓ PASS

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

記事には、バージョン情報やリリース予定など、PRの範囲を超える外部知識は含まれておらず、事実に忠実です。

時間表現の正確性 ✓ PASS

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

「これまで」「変更前は」といった過去の状態を示す表現や、「変更後」などの現在の状態を示す表現が、Diffの文脈と一致しており正確です。