Skip to content

入力欄・contenteditable要素でのキーボードテキスト選択時にポップアップメニューを起動する#313

Merged
ujiro99 merged 12 commits intomainfrom
copilot/add-popup-menu-on-text-selection
Mar 13, 2026
Merged

入力欄・contenteditable要素でのキーボードテキスト選択時にポップアップメニューを起動する#313
ujiro99 merged 12 commits intomainfrom
copilot/add-popup-menu-on-text-selection

Conversation

Copy link
Contributor

Copilot AI commented Mar 5, 2026

  • dom.tsdom/index.ts: getSelectionText() を input/textarea に対応
  • SelectAnchor.tsx: isInputOrTextarea をインポートし、キーボード選択時にアンカー位置を設定
  • ベースブランチ(main)をマージ・コンフリクト解消
  • ビルド・テスト確認(637件すべてパス)
  • getInputSelectionEndPoint() を追加:ミラーdiv技法でselectionEnd位置の正確な座標を取得
  • SelectAnchor.tsxonKeyUpgetInputSelectionEndPoint() を使い、選択終了文字近傍にアンカーを設定
  • getEditableSelectionEndPoint() を追加:Range API で contenteditable 要素の選択終了位置を取得
  • SelectAnchor.tsxonKeyUp を contenteditable 要素にも対応するよう拡張
Original prompt

This section details on the original issue you should resolve

<issue_title>入力欄でキーボード操作によりテキスト選択をした場合にも、ポップアップメニューを起動する</issue_title>
<issue_description></issue_description>

Comments on the Issue (you are @copilot in this section)


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

Copilot AI changed the title [WIP] Add popup menu on text selection via keyboard input 入力欄でのキーボードテキスト選択時にポップアップメニューを起動する Mar 5, 2026
@ujiro99 ujiro99 marked this pull request as ready for review March 10, 2026 22:53
@ujiro99
Copy link
Owner

ujiro99 commented Mar 10, 2026

@copilot ベースブランチをマージしてください。コンフリクトがあったら解消してください。

…nto copilot/add-popup-menu-on-text-selection
Copy link
Contributor Author

Copilot AI commented Mar 10, 2026

@copilot ベースブランチをマージしてください。コンフリクトがあったら解消してください。

マージ完了しました(57a0c98)。コンフリクトはなく、637件のテストすべてパスしています。

// Since Range.getBoundingClientRect() is not available for form controls,
// position the anchor at the bottom-right of the input/textarea element.
const rect = active.getBoundingClientRect()
setAnchor({ x: rect.right, y: rect.bottom })
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot
activeElementを使用すると入力欄全体が対象となり、ポップアップされる位置がキャレット位置から離れるので使用性が低下します。
以下のコードを参考に、選択されているテキストの位置を取得して、それを元にsetAnchorを行うようにしてください。

https://github.com/ujiro99/prompt-autocraft/blob/83872cc57d277f2025cead79db4359fa299339ec/src/services/dom/caretUtils.ts#L28

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

修正しました(8ee6f4b)。

dom/index.tsgetInputSelectionEndPoint() 関数を追加し、参照コードと同様のミラーdiv技法を使って selectionEnd の実際の視覚的座標を取得するようにしました。SelectAnchor.tsxonKeyUp ではこの関数を使い、要素全体の右下ではなく選択終了文字のすぐ下にアンカーを設置します。

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

対応しました(849988f4)。

dom/index.tsgetEditableSelectionEndPoint() を追加しました。window.getSelection() でRangeを取得し、選択終了位置に collapse(false) でcollapseして getBoundingClientRect() で座標を取得します。矩形が空の場合は一時的なzero-width spaceスパンで計測するフォールバックも実装しています。

SelectAnchor.tsxonKeyUpisEditable ケースを追加して対応しました。input/textarea と contenteditable の共通ロジックはまとめて重複も削減しています。

Co-authored-by: ujiro99 <677231+ujiro99@users.noreply.github.com>
Copy link
Contributor Author

Copilot AI commented Mar 11, 2026

@copilot ベースブランチをマージしてください。コンフリクトがあったら解消してください。

マージ完了しました(849988f4)。コンフリクトはなく、637件のテストすべてパスしています。

Copilot AI changed the title 入力欄でのキーボードテキスト選択時にポップアップメニューを起動する 入力欄・contenteditable要素でのキーボードテキスト選択時にポップアップメニューを起動する Mar 11, 2026
@codecov
Copy link

codecov bot commented Mar 12, 2026

Codecov Report

❌ Patch coverage is 56.06061% with 87 lines in your changes missing coverage. Please review.
✅ Project coverage is 21.71%. Comparing base (0c665af) to head (bc9dbaf).
⚠️ Report is 56 commits behind head on main.

Files with missing lines Patch % Lines
packages/extension/src/services/dom/index.ts 50.31% 79 Missing ⚠️
packages/extension/src/components/SelectAnchor.tsx 79.48% 8 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #313      +/-   ##
==========================================
+ Coverage   20.89%   21.71%   +0.81%     
==========================================
  Files         318      319       +1     
  Lines       29785    30141     +356     
  Branches     1439     1523      +84     
==========================================
+ Hits         6224     6544     +320     
- Misses      23561    23597      +36     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@claude
Copy link

claude bot commented Mar 12, 2026

コードレビュー

入力欄・contenteditable要素でのキーボードテキスト選択対応の実装を確認しました。全体的に良い実装ですが、いくつかの問題点と改善提案があります。


🐛 バグ・潜在的問題

1. 逆方向選択(Shift+左矢印)でポップアップが逆端に表示される

packages/extension/src/services/dom/index.ts

getInputSelectionEndPoint(line 308):

const caretPos = element.selectionEnd  // 常に大きい方の位置を使用

selectionStart は常に selectionEnd 以下になるよう正規化されるため、ユーザーが Shift+左矢印で逆方向に選択した場合、カーソルの実際の位置は selectionStart ですが、現在のコードは常に selectionEnd を使用します。

element.selectionDirection"backward" の場合は selectionStart を使うべきです:

const caretPos =
  element.selectionDirection === "backward"
    ? element.selectionStart ?? 0
    : element.selectionEnd ?? 0

getEditableSelectionEndPoint(line 387-388):

const endRange = range.cloneRange()
endRange.collapse(false) // 常にRangeの「後端」へ

同様に、contenteditable で Shift+左矢印による逆方向選択では selection.focusNode / selection.focusOffset(カーソルの実際の位置)を使うべきです:

const endRange = document.createRange()
endRange.setStart(selection.focusNode!, selection.focusOffset)
endRange.setEnd(selection.focusNode!, selection.focusOffset)

2. ライブDOM変更によるサイドエフェクトのリスク

packages/extension/src/services/dom/index.ts:396-399

const span = document.createElement("span")
span.textContent = "\u200B"
endRange.insertNode(span)  // ← ライブDOMを変更
const spanRect = span.getBoundingClientRect()
span.remove()

insertNode はページのライブDOMに要素を挿入するため、MutationObserver のトリガーや、React/Vue等フレームワークが管理するコンポーネントで予期しない再レンダリングを引き起こす可能性があります。

getInputSelectionEndPoint で使用しているミラーdiv技法と同様のアプローチ(document.body に off-screen 要素を追加)のほうが安全かもしれません。


3. onKeyUp が全キー操作でトリガーされる

packages/extension/src/components/SelectAnchor.tsx:163-194

入力欄にフォーカスがある状態で全てのキー操作(Enter、Backspace、矢印キー単体など)で getSelectionText() が呼ばれます。テキスト未選択時は releaseAnchor(true) が即座に呼ばれるため、機能的には正しいですが、不要な計算が毎キー押下で発生します。

選択に関係するキー(Shift、Ctrl+A等)のみ処理するよう e: KeyboardEvent を引数で受け取り e.shiftKey / e.key === "a" などでフィルタすることを検討してください。


⚠️ コード品質

4. マジックナンバー

packages/extension/src/components/SelectAnchor.tsx:183

selectionEndPoint.y -= 8 // Adjust to match the mouse cursor selection.

8 は定数として定義すべきです。例:

const KEYBOARD_SELECTION_Y_OFFSET = 8

5. getInputSelectionEndPoint のスタイルリスト(軽微)

packages/extension/src/services/dom/index.ts:313-327

overflow プロパティが stylesToCopy に含まれていません。ミラーdivには overflow: hidden が明示的に設定されていますが、padding-left/right 等個別の padding プロパティではなくショートハンドの padding をコピーしています。getComputedStyle はショートハンドも解決して返すため動作しますが、padding-top/right/bottom/left を個別にコピーする方が明示的で安全です。


🧪 テストカバレッジ

新規追加された関数のテストが存在しません:

  • getInputSelectionEndPointpackages/extension/src/services/dom/index.ts:303
  • getEditableSelectionEndPointpackages/extension/src/services/dom/index.ts:379
  • getSelectionText の input/textarea 対応部分(line 84-88)

CLAUDE.md のガイドラインに従い、少なくとも以下のケースをテストすることを推奨します:

  • 順方向選択(Shift+右矢印)
  • 逆方向選択(Shift+左矢印)
  • テキストなし(空選択)
  • スクロールされた長いテキストの入力欄

✅ 良い点

  • ミラーdiv技法は <input> / <textarea> の選択位置取得に適した手法です。try/finally でDOMクリーンアップを保証している点も良いです。
  • getInputSelectionEndPoint のフォールバック処理(line 174-177)でポップアップが必ず表示されるようになっている点が堅牢です。
  • コメントが充実しており、技術的な判断理由(Range APIが使えない理由等)が明確に記述されています。

@claude
Copy link

claude bot commented Mar 12, 2026

PR レビュー: キーボードテキスト選択によるポップアップ対応 (Input/contenteditable)

概要

このPRは <input><textarea>contenteditable 要素でキーボードによるテキスト選択時にポップアップメニューを表示する機能を追加しています。変更の主な内容は以下の通りです:

  1. packages/extension/src/services/dom/index.ts: getSelectionText() がフォームコントロールの selectionStart/selectionEnd を読み取るよう拡張、および getInputSelectionEndPoint()getEditableSelectionEndPoint() が新規追加
  2. packages/extension/src/components/SelectAnchor.tsx: onKeyUp ハンドラがフォームコントロールと contenteditable 要素を検出し、選択座標を取得して setAnchor()/releaseAnchor() を呼び出す

潜在的なバグ

🔴 [High] selection.focusNode の非nullアサーションによるクラッシュリスク

ファイル: packages/extension/src/services/dom/index.ts L491-492

endRange.setStart(selection.focusNode!, selection.focusOffset)
endRange.setEnd(selection.focusNode!, selection.focusOffset)

L484 で selection.rangeCount > 0 を確認していますが、focusNode はプログラム的に作成された選択やシャドウDOM境界をまたぐ場合に null になりえます。null の場合、TypeError でクラッシュします。

修正案:

if (!selection.focusNode) return null
endRange.setStart(selection.focusNode, selection.focusOffset)
endRange.setEnd(selection.focusNode, selection.focusOffset)

🟡 [Medium] selectionEndPointnull の場合に古いポップアップが残留する

ファイル: packages/extension/src/components/SelectAnchor.tsx L196-200

if (selectionEndPoint) {
  selectionEndPoint.y -= 8
  setAnchor(selectionEndPoint)
}
return  // ← selectionEndPoint が null でもここで終了してしまう

getEditableSelectionEndPoint()null を返した場合、前回表示したポップアップが消えないまま残ります。null のときは releaseAnchor(true) を呼ぶべきです。

修正案:

if (selectionEndPoint) {
  selectionEndPoint.y -= 8
  setAnchor(selectionEndPoint)
} else {
  releaseAnchor(true)
}
return

🟡 [Medium] findContentEditableRootcontenteditable="inherit" を見落とす

ファイル: packages/extension/src/services/dom/index.ts L387-390

(current.getAttribute("contenteditable") === "true" ||
  current.getAttribute("contenteditable") === "")

contenteditable="inherit" が設定されているが isContentEditable === true の要素(リッチテキストエディタで一般的)を見落とします。measurePositionInClonenull が渡り座標取得に失敗します。

修正案:

if (current instanceof HTMLElement && current.isContentEditable) {

🟡 [Medium] RTL テキスト入力で座標計算が誤る

ファイル: packages/extension/src/services/dom/index.ts L316-330

stylesToCopy の配列からミラー div にコピーするスタイルに directionunicode-bidi が含まれていません。アラビア語・ヘブライ語等の右書き入力では、ミラー div の文字配置が実際の入力と異なり、ポップアップ位置がずれます。

stylesToCopy"direction""unicode-bidi" を追加してください。


コード品質の問題

🟢 [Low] マジックナンバー 8 を名前付き定数に

ファイル: packages/extension/src/components/SelectAnchor.tsx L197

selectionEndPoint.y -= 8 // Adjust to match the mouse cursor selection.

L17 付近の SIZE = 40 と同様に、定数として定義することで意図が明確になります。

const KEYBOARD_SELECTION_Y_OFFSET = 8
// ...
selectionEndPoint.y -= KEYBOARD_SELECTION_Y_OFFSET

パフォーマンスの考慮事項

🟢 [Low] keyup イベントごとにDOMの強制リフローが発生

ファイル: packages/extension/src/services/dom/index.ts L303-376 (getInputSelectionEndPoint)、L429-474 (measurePositionInClone)

どちらの関数も document.body にDOM要素を追加・測定・削除する処理を行い、keyup イベントのたびに強制レイアウト(リフロー)を引き起こします。Shift+矢印キーを押し続けると高頻度で発火します。

ミラー div をキャッシュするか、requestAnimationFrame でイベントを間引くことを検討してください。

🟢 [Low] findEquivalentNode の O(n²) パス探索

ファイル: packages/extension/src/services/dom/index.ts L401-423

Array.from(parent.childNodes).indexOf(...) をDOMの深さ×幅の回数だけ実行しています。大きな contenteditable ドキュメントでは低速になる可能性があります。


テストカバレッジ

🔴 新規コードのテストがほぼゼロ

PRのコメントに記載の通り:

  • dom/index.ts: パッチカバレッジ 2.43% (新規約80行が未テスト)
  • SelectAnchor.tsx: パッチカバレッジ 0.00% (新規約28行が未テスト)

不足しているテストの例:

  • getSelectionText(): input要素で選択テキストが正しく返される
  • getInputSelectionEndPoint(): 通常の <input type="text"> で座標が正しく計算される
  • getInputSelectionEndPoint(): backward方向の選択でcaretPosが selectionStart になる
  • getEditableSelectionEndPoint(): rangeCount === 0 のとき null を返す
  • getEditableSelectionEndPoint(): focusNodenull のとき null を返す(上記クラッシュバグの回帰テスト)
  • SelectAnchor.tsx: Shift+矢印キーで setAnchor が呼ばれる
  • SelectAnchor.tsx: 非選択キーでは何も起きない

重大度まとめ

重大度 問題 場所
🔴 High focusNode non-null アサーション — クラッシュリスク dom/index.ts:491-492
🟡 Medium selectionEndPointnull 時に古いポップアップが残留 SelectAnchor.tsx:196-200
🟡 Medium findContentEditableRootcontenteditable="inherit" を見落とし dom/index.ts:387-390
🟡 Medium RTL 入力で座標計算が誤る (direction スタイル未コピー) dom/index.ts:316-330
🟢 Low マジックナンバー 8 を定数化すべき SelectAnchor.tsx:197
🟢 Low keyup ごとにリフロー発生 dom/index.ts:312, 354-374
🟢 Low findEquivalentNode の O(n²) パス探索 dom/index.ts:401-423
ℹ️ Info 新規コードのテストがほぼゼロ (約108行未テスト)

全体として、機能の実装アプローチは適切で、ミラーdivテクニックやRange APIの活用は妥当な手法です。上記のHighおよびMediumの問題を修正すれば、安定した動作が期待できます。

@claude
Copy link

claude bot commented Mar 13, 2026

コードレビュー

PR の目的(input/textarea・contenteditable 要素でのキーボードテキスト選択時にポップアップを起動)は明確で、実装方針も適切です。テストも豊富に追加されており、ほぼ良い状態です。以下に気になった点を挙げます。


🔴 潜在的なバグ

1. measurePositionInClone で focusOffset が範囲外になる可能性
packages/extension/src/services/dom/index.tsmeasurePositionInClone 内:

cloneRange.setStart(cloneNode, focusOffset)
cloneRange.setEnd(cloneNode, focusOffset)

cloneNode が要素ノード(Element)の場合、focusOffsetcloneNode.childNodes.length を超えていると IndexSizeError が throw されます。cloneNode がテキストノードかどうかをチェックしてから offset を設定するか、try-catch で保護することを推奨します。

2. getEditableSelectionEndPoint の JSDoc コメントが誤っている
packages/extension/src/services/dom/index.tsgetEditableSelectionEndPoint の JSDoc:

* @returns {Point | null} The bottom-left coordinates of the selection end position.

実際に返す値は { x: rect.left, y: rect.bottom } で、「bottom-left」ではなく「left edge, bottom」です。また measurePositionInClone も同様に x: origRect.left + (spanRect.left - cloneRect.left) を返しており、「bottom-left」という表現は正確ではありません。コメントを修正してください。


🟡 設計・ロジックの懸念

3. white-space の二重設定(冗長)
packages/extension/src/services/dom/index.tsgetInputSelectionEndPoint

const stylesToCopy = [
  ...
  "white-space",  // ここでコピーされるが...
  ...
]
// 後で必ず上書きされる
if (element instanceof HTMLTextAreaElement) {
  mirror.style.whiteSpace = "pre-wrap"  // 上書き
} else {
  mirror.style.whiteSpace = "nowrap"    // 上書き
}

stylesToCopy"white-space" は必ず後続コードで上書きされるため冗長です。stylesToCopy から削除してください。

4. Meta/Control の単独 keyup でも選択処理が走る
packages/extension/src/components/SelectAnchor.tsxonKeyUp ハンドラ:

const mayChangeSelection =
  e.shiftKey ||
  (e.key === "a" && (e.ctrlKey || e.metaKey)) ||
  e.key === "Meta" ||   // ← Ctrl/Cmd を単体で押して離しただけでも発火する
  e.key === "Control"

コメントに「macOS で Cmd+A の keyup が 'a' ではなく Meta のみ発火する場合がある」とありますが、Meta/Control を単体で押して離した場合も getSelectionText() が呼び出されます。point != null(既に選択中)の場合は意図的に処理されますが、コメントにその旨を明記するか、より条件を絞ることを検討してください。

5. マジックナンバー y -= 8
packages/extension/src/components/SelectAnchor.tsx

selectionEndPoint.y -= 8 // Adjust to match the mouse cursor selection.

8 はマジックナンバーです。KEYBOARD_ANCHOR_Y_OFFSET = 8 のような定数として抽出し、他の定数(SIZE = 40 など)と並べて定義することを推奨します。

6. findContentEditableRootcontenteditable="inherit" が考慮されていない
packages/extension/src/services/dom/index.ts

current.getAttribute("contenteditable") === "true" ||
current.getAttribute("contenteditable") === ""

contenteditable="inherit" は有効な値で、親要素の編集可能状態を継承します。ネストされた要素では isContentEditabletrue になる場合があります。すでに current.isContentEditable を見ていれば十分なので、getAttribute による条件を削除して current.isContentEditable のみで判断する方がシンプルです。


🟢 テスト品質

7. フォールバック(getInputSelectionEndPoint が null の場合)のテストが不足
packages/extension/src/components/SelectAnchor.tsx の以下のパス:

if (!selectionEndPoint) {
  const rect = active.getBoundingClientRect()
  selectionEndPoint = { x: rect.right, y: rect.bottom }
}

getInputSelectionEndPoint が null を返した場合に getBoundingClientRect() でフォールバックするパスがテストされていません。SelectAnchor.test.tsx に対応するテストケース(例: SA-07)の追加を推奨します。

8. SA-01SA-02 でアンカー位置の実際の設定を検証していない
packages/extension/src/components/SelectAnchor.test.tsx の SA-01/02:

expect(mockGetSelectionText).toHaveBeenCalled()
expect(mockGetInputSelectionEndPoint).toHaveBeenCalled()

関数が「呼ばれたこと」のみ検証しており、実際に setAnchor(→ DOM への反映)が正しく行われたかを検証していません。mockSetTarget や anchor の DOM 状態を検証するとテストの信頼性が向上します。


✅ 良い点

  • ミラー div 技法による input/textarea の選択終了位置の取得は適切なアプローチです。
  • finally ブロックでミラー div を確実にクリーンアップしており、DOM 汚染がありません(GISEP-05 でも検証済み)。
  • getSelectionText の変更で input/textarea の選択テキストを取得できるようになっており、既存の動作への影響も最小限です。
  • テストケース命名規則(SA-01GISEP-01 など)が CLAUDE.md のガイドラインに沿っています。
  • mayChangeSelection のロジックでパフォーマンスを考慮している点(point がない場合は不要なキーで早期リターン)は適切です。

@ujiro99 ujiro99 merged commit b66eb42 into main Mar 13, 2026
6 checks passed
@ujiro99 ujiro99 deleted the copilot/add-popup-menu-on-text-selection branch March 13, 2026 03:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

入力欄でキーボード操作によりテキスト選択をした場合にも、ポップアップメニューを起動する

2 participants