Ctrl/Cmd+クリックでリンクを開く機能をcontenteditable切り替えで実現

basecamp/lexxy

リッチテキストエディタ内のリンクを、Ctrl/Cmd を押しながらクリックすることでブラウザネイティブのリンクナビゲーションに委譲する LinkOpenerExtension が追加されました。window.open を直接呼び出す方式ではなく、contenteditable 属性の動的な切り替えという手法を採用することで、ホバー時の視覚フィードバックを含むエッジケースを網羅しています。

背景

リッチテキストエディタ内のリンクは、クリックイベントがエディタに奪われるため、ブラウザの標準的なリンク動作が働きません。この問題を解消する単純な解決策として、cmd/ctrl+click を捕捉して window.open を呼び出す方法がありますが、これにはホバー時のカーソル変化(手のひらアイコン)が伴いません。

PR本文では、モディファイアキーの状態を追跡してカーソルスタイリングを別途管理する方法も検討されています。しかし、この方法には落とし穴があります。ブラウザウィンドウ外でキーを押した状態でウィンドウに戻った場合、ポインターイベントのモディファイアキー状態が正確に反映されないケースがあるためです。こうした複雑なエッジケースを避けるため、ブラウザのネイティブ動作に委譲するアプローチが選ばれました。

技術的な変更

新たに追加された LinkOpenerExtensionLexxyExtension を継承し、Lexical の defineExtension / mergeRegister を用いて window の複数のイベントに登録されます。リッチテキストをサポートするエディタ(supportsRichText)でのみ有効化されます。

コアのロジックは、Ctrl/Cmd キーの押下・解放を keydown / keyup で監視し、エディタ内のすべての <a> 要素に対して属性を付け外しする点にあります。

キー押下時(#enable):

#enable() {
  for (const anchor of this.#anchors) {
    anchor.setAttribute("contenteditable", "false")
    anchor.setAttribute("target", "_blank")
    anchor.setAttribute("rel", "noopener noreferrer")
  }
}

キー解放時(#disable):

キー解放時は contenteditabletargetrel 属性がすべて取り除かれ、エディタの通常状態に戻ります。

プラットフォーム判定には @lexical/utilsIS_APPLE フラグが利用されており、macOS では metaKey、それ以外では ctrlKey を参照します。

#isModified(event) {
  return IS_APPLE ? event.metaKey : event.ctrlKey
}

ブラウザのタブ切り替え時に生じるイベントの不整合については、focus イベントで #refresh が呼ばれ、200ms のディレイ後に mousemove を1回だけ購読することで状態を再同期します。PR コメントによれば、Chrome はタブ変更後しばらくの間、モディファイアキーなしのイベントを発行するため、このバッファが必要とされています。

#refresh() {
  // Chrome dispatches events without modifier keys *for a while* after changing tabs
  setTimeout(() => {
    window.addEventListener("mousemove", this.#update.bind(this), { once: true })
  }, 200)
}

また、editor.js への統合は最小限で、既存の Extension 配列への追加のみです。

// 変更前
import { FormatEscapeExtension } from "../extensions/format_escape_extension.js"

extensions() {
  return [
    // ...
    FormatEscapeExtension
  ]
}

// 変更後
import { FormatEscapeExtension } from "../extensions/format_escape_extension.js"
import { LinkOpenerExtension } from "../extensions/link_opener_extension.js"

extensions() {
  return [
    // ...
    FormatEscapeExtension,
    LinkOpenerExtension
  ]
}

Playwright によるブラウザテストも追加されており、「モディファイアキー押下で contenteditable="false" が付与される」「targetrel が設定される」「エディタ内の複数リンクに一括適用される」の3ケースをカバーしています。

設計判断

contenteditable="false" の動的付け外しという手法が中心的な設計判断です。contenteditable="false" が付与されたアンカー要素はブラウザによって編集不可のインラインコンテンツとして扱われ、通常の <a> タグとしてのリンク動作・カーソル表示が自動的に復元されます。これにより、カーソルのビジュアルフィードバックとクリック動作の両方をブラウザに委譲でき、独自実装が不要になります。

target="_blank"rel="noopener noreferrer" をセットで付与している点も注目に値します。新しいタブでリンクを開きつつ、noopener でオープナーオブジェクトへのアクセスを遮断し、noreferrer でリファラー情報の送信を防いでいます。これはリッチテキストエディタがユーザー入力のURLを扱う性質上、セキュリティ上の配慮として合理的な判断といえます。

また、状態管理を最小限に抑えた設計も特徴的です。キーの状態を JavaScript の変数として保持するのではなく、DOM 属性の有無を唯一の真実のソース(source of truth)として扱うことで、状態の不整合が生じる余地を排除しています。

まとめ

本 PR は、リッチテキストエディタの編集コンテキストとブラウザのリンク動作という相反する要求を、contenteditable 属性の動的な切り替えという単純な機構で両立させた変更です。ブラウザのネイティブ動作を最大限に活用することで、カーソルビジュアル・クリック動作・セキュリティ属性の付与を一貫した実装で実現しており、モディファイアキーの状態追跡にまつわるブラウザ間の差異も #refresh による再同期で吸収しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
95de83fe

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

品質レビュー結果

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

Review Criteria:

記事構成 ✓ PASS

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

「リード文(総論)→背景・技術的な変更・設計判断(各論)→まとめ(結論)」という3部構成が明確に適用されており、非常に分かりやすい構成です。

カスタムMarkdown構文 ✓ PASS

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

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

対象読者への適合性 ✓ PASS

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

contenteditable, Lexical拡張、イベントリスナーなど、専門知識を持つエンジニアを対象とした適切な技術レベルと表現で書かれています。

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

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

各セクションが総論→各論の構成になっており、各段落はトピックセンテンスで始まり、1段落1トピックが守られています。可読性が非常に高いです。

Diff内容との照合 ⚠ WARNING

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

引用されているコードの大部分はDiffと一致していますが、`#disable()`メソッドの実装が提供されたDiffに含まれていないため、キー解放時の動作に関する説明を完全には照合できませんでした。ただし、これはDiffの不完全さが原因であり、記事の説明は技術的に妥当です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

`contenteditable`, `LexxyExtension`, `IS_APPLE`, `noopener`, `noreferrer`などの技術用語が正確かつ適切な文脈で使用されています。

説明の技術的正確性 ✓ PASS

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

「`contenteditable`を切り替えることでブラウザのネイティブ動作を利用する」という変更の核心的な理由や、タブ切り替え時のエッジケース(`#refresh`)に関する説明が、PR情報やDiff内のコメントと一致しており、技術的に正確です。

事実の突合 ✓ PASS

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

記事内の主張はすべてPRのDescription、Diff内のコード、コードコメント、テストコードから裏付けられており、ハルシネーション(捏造)は見られません。

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

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

PR番号(#976)や`#refresh`メソッド内の遅延時間(200ms)など、記事に含まれる数値や固有名詞は正確です。

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

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

記事のタイトル「Ctrl/Cmd+クリックでリンクを開く機能をcontenteditable切り替えで実現」は、PRのタイトル「Open links with ctrl/cmd+click」の内容をより具体的に、かつ正確に表現しています。

外部知識の正確性 ✓ PASS

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

PR情報に基づかない外部知識(バージョンのサポート状況、リリース日程など)の追加はありませんでした。「noopener」「noreferrer」の解説は一般的な知識ですが、コードの意図を説明するための妥当な補足です。

時間表現の正確性 ✓ PASS

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

PR情報に時間に関する表現がないため、時間表現の歪曲はありませんでした。