Ctrl/Cmd+クリックでリンクを開く機能をcontenteditable切り替えで実現
リッチテキストエディタ内のリンクを、Ctrl/Cmd を押しながらクリックすることでブラウザネイティブのリンクナビゲーションに委譲する LinkOpenerExtension が追加されました。window.open を直接呼び出す方式ではなく、contenteditable 属性の動的な切り替えという手法を採用することで、ホバー時の視覚フィードバックを含むエッジケースを網羅しています。
背景
リッチテキストエディタ内のリンクは、クリックイベントがエディタに奪われるため、ブラウザの標準的なリンク動作が働きません。この問題を解消する単純な解決策として、cmd/ctrl+click を捕捉して window.open を呼び出す方法がありますが、これにはホバー時のカーソル変化(手のひらアイコン)が伴いません。
PR本文では、モディファイアキーの状態を追跡してカーソルスタイリングを別途管理する方法も検討されています。しかし、この方法には落とし穴があります。ブラウザウィンドウ外でキーを押した状態でウィンドウに戻った場合、ポインターイベントのモディファイアキー状態が正確に反映されないケースがあるためです。こうした複雑なエッジケースを避けるため、ブラウザのネイティブ動作に委譲するアプローチが選ばれました。
技術的な変更
新たに追加された LinkOpenerExtension は LexxyExtension を継承し、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):
キー解放時は contenteditable・target・rel 属性がすべて取り除かれ、エディタの通常状態に戻ります。
プラットフォーム判定には @lexical/utils の IS_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" が付与される」「target と rel が設定される」「エディタ内の複数リンクに一括適用される」の3ケースをカバーしています。
設計判断
contenteditable="false" の動的付け外しという手法が中心的な設計判断です。contenteditable="false" が付与されたアンカー要素はブラウザによって編集不可のインラインコンテンツとして扱われ、通常の <a> タグとしてのリンク動作・カーソル表示が自動的に復元されます。これにより、カーソルのビジュアルフィードバックとクリック動作の両方をブラウザに委譲でき、独自実装が不要になります。
target="_blank" と rel="noopener noreferrer" をセットで付与している点も注目に値します。新しいタブでリンクを開きつつ、noopener でオープナーオブジェクトへのアクセスを遮断し、noreferrer でリファラー情報の送信を防いでいます。これはリッチテキストエディタがユーザー入力のURLを扱う性質上、セキュリティ上の配慮として合理的な判断といえます。
また、状態管理を最小限に抑えた設計も特徴的です。キーの状態を JavaScript の変数として保持するのではなく、DOM 属性の有無を唯一の真実のソース(source of truth)として扱うことで、状態の不整合が生じる余地を排除しています。
まとめ
本 PR は、リッチテキストエディタの編集コンテキストとブラウザのリンク動作という相反する要求を、contenteditable 属性の動的な切り替えという単純な機構で両立させた変更です。ブラウザのネイティブ動作を最大限に活用することで、カーソルビジュアル・クリック動作・セキュリティ属性の付与を一貫した実装で実現しており、モディファイアキーの状態追跡にまつわるブラウザ間の差異も #refresh による再同期で吸収しています。