エディタプロンプトのコードブロック無効化と表示位置の修正

basecamp/lexxy

リッチテキストエディタ「Lexxy」のプロンプト(インライン補完メニュー)について、コードブロック内での誤発火を防ぎ、画面端でのクリッピングと表示ずれを修正する複数の改善が加えられました。

背景

Lexxyのプロンプト機能は、エディタ内でトリガー文字を入力すると補完メニューが表示される仕組みですが、コードブロック内でも同様に発火してしまう問題がありました。コードを記述中に補完メニューが出現することは、コンテキスト的に不適切です。

また、表示位置に関しても2つの問題がありました。プロンプトのポップオーバーが画面右端を超える場合に右側へはみ出してクリップされる問題と、#755 で追加されたノード削除ボタンの配置がずれている問題が存在していました。さらに、.lexxy-contentul セレクタが広すぎるために、プロンプトメニュー(.lexxy-prompt-menu)まで左側マージンが適用されてしまっていました。

技術的な変更

コードブロック内でのプロンプト無効化

src/elements/prompt.jsLexicalPromptElement に、コードブロック判定のガード処理が追加されました。

変更前:

#addTriggerListener() {
  const unregister = this.#editor.registerUpdateListener(({ editorState }) => {
    editorState.read(() => {
      const { node, offset } = this.#selection.selectedNodeWithOffset()
      // ...
    })
  })
}

#addCursorPositionListener() {
  this.cursorPositionListener = this.#editor.registerUpdateListener(() => {
    if (this.closed) return

    this.#editor.read(() => {
      const { node, offset } = this.#selection.selectedNodeWithOffset()
      // ...
    })
  })
}

変更後:

#addTriggerListener() {
  const unregister = this.#editor.registerUpdateListener(({ editorState }) => {
    editorState.read(() => {
      if (this.#selection.isInsideCodeBlock) return

      const { node, offset } = this.#selection.selectedNodeWithOffset()
      // ...
    })
  })
}

#addCursorPositionListener() {
  this.cursorPositionListener = this.#editor.registerUpdateListener(({ editorState }) => {
    if (this.closed) return

    editorState.read(() => {
      if (this.#selection.isInsideCodeBlock) {
        this.#hidePopover()
        return
      }

      const { node, offset } = this.#selection.selectedNodeWithOffset()
      // ...
    })
  })
}

#addTriggerListener ではコードブロック内であればトリガー評価自体をスキップし、#addCursorPositionListener ではすでにポップオーバーが表示されている場合に #hidePopover() を呼び出して閉じます。また、後者のリスナーは this.#editor.read() から editorState.read() に切り替えられており、コールバックに渡された editorState を直接利用するようになっています。

ポップオーバーの右端クリッピング対応

ポップオーバーの位置計算ロジックが、垂直方向(下端)に加えて水平方向(右端)のクリッピング検出を行うよう変更されました。

変更前:

if (!this.popoverElement.hasAttribute("data-anchored")) {
  this.popoverElement.style.left = `${x}px`
  this.popoverElement.toggleAttribute("data-anchored", true)
}

this.popoverElement.style.top = `${y + verticalOffset}px`
this.popoverElement.style.bottom = "auto"

const popoverRect = this.popoverElement.getBoundingClientRect()
const isClippedAtBottom = popoverRect.bottom > window.innerHeight

if (isClippedAtBottom || this.popoverElement.hasAttribute("data-clipped-at-bottom")) {
  this.popoverElement.style.top = `${y + verticalOffset - popoverRect.height - fontSize}px`
  this.popoverElement.style.bottom = "auto"
}

変更後:

if (!this.popoverElement.hasAttribute("data-anchored")) {
  this.#setPopoverOffsetX(x)
  this.#setPopoverOffsetY(y + verticalOffset)
  this.popoverElement.toggleAttribute("data-anchored", true)
}

const popoverRect = this.popoverElement.getBoundingClientRect()

if (popoverRect.right > window.innerWidth) {
  this.popoverElement.toggleAttribute("data-clipped-at-right", true)
}

style.left / style.top への直接代入が #setPopoverOffsetX / #setPopoverOffsetY メソッドへの呼び出しに置き換えられ、位置設定のロジックがカプセル化されました。右端検出では popoverRect.right > window.innerWidth を判定条件とし、data-clipped-at-right 属性をトグルすることでCSSによる右寄せ表示を制御します。

CSSのセレクタとスタイルの修正

app/assets/stylesheets/lexxy-content.css では、ul セレクタを絞り込んでプロンプトメニューへの意図しないスタイル適用を排除しています。

変更前:

ol, ul {
  margin-inline-start: calc(var(--lexxy-content-margin) * 1.5);
  padding: 0;
}

変更後:

ol, ul:not(.lexxy-prompt-menu) {
  margin-inline-start: calc(var(--lexxy-content-margin) * 1.5);
  padding: 0;
}

ul:not(.lexxy-prompt-menu) に変更することで、コンテンツ内のリストにのみ左マージンが適用され、プロンプトメニューは影響を受けなくなります。

app/assets/stylesheets/lexxy-editor.css では、lexxy-node-delete-button の配置が修正されました。削除ボタンのスタイル定義の重複が整理され、添付ファイルノード(attachment node)向けの lexxy-node-delete-button には inset-inline-start: unset が追加されて横方向の位置指定の競合が解消されています。

テストの追加

コードブロック内でのプロンプト非表示を確認するシステムテストが追加されました。

test "prompt does not trigger inside code block" do
  find_editor.toggle_command("insertCodeBlock")
  find_editor.send "1"

  assert_no_css ".lexxy-prompt-menu--visible"
end

設計判断

data-* 属性によるステート管理 がポップオーバーの表示制御に一貫して採用されています。data-clipped-at-rightdata-anchored をDOMに持たせ、CSSがそれを参照して表示を切り替える構造は、JavaScriptとCSSの責務を明確に分離します。

コードブロック判定を this.#selection.isInsideCodeBlock として #selection オブジェクトに委譲していることも、関心の分離という観点で一貫した設計です。トリガー処理とカーソル位置監視の両方で同じプロパティを参照することで、コードブロック判定ロジックの一元管理が実現されています。ul セレクタの絞り込みも、グローバルなスタイルルールが内部コンポーネントに干渉しないよう影響範囲を明示的に制限する判断です。

まとめ

本PRは、プロンプト機能の誤作動・表示崩れ・スタイル汚染という複数の独立した問題を一括して修正しています。コードブロック判定のガード、クリッピング検出軸の拡張、CSSセレクタの精緻化、削除ボタン配置の整理という各修正はそれぞれ小規模ですが、エディタUIとしての信頼性を着実に向上させる変更です。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
58d0ed5a

この記事は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リンク記法の正確性

ファイル名付きシンタックスハイライトやGitHub PRへのリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

専門用語を適切に用い、エンジニア読者に向けた適切な技術レベルで記述されています。

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

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

各セクション、各パラグラフが構造化されており、トピックセンテンスが明確で非常に読みやすいです。

Diff内容との照合 ✓ PASS

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

記事で引用されているコードは、提供されたDiffの内容と完全に一致しており、ファイルパスも妥当です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「ポップオーバー」「クリッピング」「セレクタ」「ガード処理」などの技術用語が文脈に応じて正確に使用されています。

説明の技術的正確性 ✓ PASS

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

コード変更の意図や影響に関する説明が、Diffの内容に基づき、技術的に正確かつ論理的に行われています。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのDescriptionやDiff内のコードで裏付けられており、ハルシネーションは検出されませんでした。「設計判断」のセクションもコードから読み取れる内容であり、妥当な解説となっています。

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

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

PR番号(#803)、関連PR番号(#755)などの数値や固有名詞はすべて正確です。

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

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

記事のタイトルはPRの主要な変更点を的確に要約しており、内容との整合性が高いです。

外部知識の正確性 ✓ PASS

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

PR情報に基づかない外部知識(バージョン情報、リリース予定など)の追加はなく、事実に基づいた記述に徹しています。

時間表現の正確性 ✓ PASS

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

PR内の「recently added」といった時間表現が「[#755] で追加された」と正確に反映されており、時間的な歪曲はありません。