`<lexxy-prompt>`に`only-at`属性とコンテンツタイプゲートの迂回を追加
<lexxy-prompt>に2つの機能が追加された。トリガー文字を発火させる位置を正規表現で制御するonly-at属性と、insert-editable-textプロンプトがエディタのアタッチメント制限に関わらず常に登録されるよう#promptContentTypePermittedゲートを短絡させる修正だ。
背景
この変更はBC4(Basecamp 4)のボット選択プロンプトの実装要件から生まれた。ボット選択プロンプトでは!がチャット行の先頭にある場合にのみ発火し、かつAttachment plumbingを使わずにプレーンテキストとして挿入する必要がある。
既存の実装には2つの制約があった。第一に、トリガーの発火条件がハードコードされており、「行頭またはスペース・改行の直後」という固定ルールしか持てなかった。第二に、insert-editable-text属性を持つプロンプトであっても#promptContentTypePermittedのチェックを通過する必要があり、エディタがpermitted-attachment-typesを制限していたりattachments="false"を設定していたりすると、トリガーリスナーが登録されずサイレントに失敗していた。
技術的な変更
only-at属性の実装はsrc/elements/prompt.jsとsrc/helpers/lexical_helper.jsにまたがる。
変更前の実装では、トリガー検出ロジックが現在のテキストノード内の文字列のみを参照していた。トリガーの直前の1文字を調べ、スペース・改行であるかをチェックするシンプルな実装だった:
// 変更前
const isAtStart = offset === triggerLength
const charBeforeTrigger = offset > triggerLength ? fullText[offset - triggerLength - 1] : null
const isPrecededBySpaceOrNewline = charBeforeTrigger === " " || charBeforeTrigger === "\n"
if (isAtStart || isPrecededBySpaceOrNewline) {
this.#popoverListeners.dispose()
this.#showPopover()
}
変更後は、ドキュメント全体のトリガー位置までのテキストを取得し、only-at属性の正規表現に対してマッチを試みる:
// 変更後
const DEFAULT_ONLY_AT_PATTERN = "^|[ \\n]"
// ...
const textBeforeTrigger = $textBeforeOffset(node, offset - triggerLength)
if (this.#onlyAtRegExp.test(textBeforeTrigger)) {
this.#popoverListeners.dispose()
this.#showPopover()
}
このアプローチのキーとなるのが、新たに追加された $textBeforeOffset 関数だ。src/helpers/lexical_helper.jsに実装されており、Lexicalのノードツリーを深さ優先で走査してtargetNodeのoffset位置までのテキストを収集する。非インライン要素の境界では`
を挿入することで、LexicalのElementNodeのgetTextContent()`の動作と一致させている:
export function $textBeforeOffset(targetNode, offset) {
const parts = []
let done = false
function visit(node) {
if (done) return
if (node === targetNode) {
parts.push(node.getTextContent().slice(0, offset))
done = true
return
}
if ($isElementNode(node)) {
const children = node.getChildren()
for (let i = 0; i < children.length; i++) {
visit(children[i])
if (done) return
const child = children[i]
if ($isElementNode(child) && !child.isInline() && i < children.length - 1) {
parts.push("\n\n")
}
}
} else {
parts.push(node.getTextContent())
}
}
visit($getRoot())
return parts.join("")
}
デフォルト値の DEFAULT_ONLY_AT_PATTERN(^|[ \n])はパターン末尾に自動的にアンカーされるため、only-atを設定しない既存の<lexxy-prompt>の動作は変わらない。
#promptContentTypePermittedの短絡については、insert-editable-text属性が存在する場合に早期リターンする分岐を追加したとPR説明に記載されている。アタッチメントが作成されないプロンプトに対して、エディタのアタッチメント許可チェックをバイパスする。
only-at属性が取り得るパターンの例を以下に示す:
-
only-at="^":ドキュメントの絶対先頭のみで発火(別段落の先頭には反応しない) -
only-at=".*":任意の位置で発火(単語の途中でも#が使えるハッシュタグ用途) - 省略時:デフォルトの
^|[ \n]が適用され、行頭または空白・改行後に発火
設計判断
ドキュメント全体を参照するアーキテクチャが採用された点が重要な設計判断だ。以前の実装は現在のテキストノード内だけを参照していたため、複数段落にまたがるコンテキストを判定できなかった。$textBeforeOffsetがLexicalのノードツリーを$getRoot()から走査し、段落境界を`
`として正規化することで、正規表現に渡されるテキストがエディタ全体の内容を正確に反映する。
正規表現の末尾アンカーも意識的な選択だ。ユーザーが「直前に何があるべきか」だけを記述すれば済むように設計されており、^という1文字のパターンで「ドキュメントの最初の文字」という条件が自然に表現できる。only-at="^"のとき、2段落目の先頭で!を打っても発火しないのは、渡されるテキストが`"hello
"であり^(末尾アンカー付き)にマッチしないためだ。これはPlaywrightテストのonly_at.test.js`でも明示的に検証されている。
insert-editable-textの短絡は、責務の分離という観点からも整合している。アタッチメントを作成しないプロンプトに対してアタッチメントタイプのゲートチェックを通過させるのは設計上の矛盾であり、今回の修正はその不整合を解消している。
まとめ
only-at属性は、トリガーの発火条件をノードレベルからドキュメント全体の文脈へと引き上げることで、@メンション・ボット選択・ハッシュタグといった多様なユースケースを単一の設定インターフェースで表現できるようにした。insert-editable-textの短絡修正と合わせて、アタッチメント非対応のエディタ環境でもテキスト挿入型プロンプトが正しく機能するようになった。