フォーカス中のエディタへの値設定時、DOM選択状態の更新をスキップしないよう修正
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 と $onUpdate が lexical からの 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を活用して汎用的に解決しており、フォーカスなしのケースでは従来の最適化が引き続き有効です。