マークダウンペースト時に `lexxy:insert-markdown` イベントを発火
Lexxyエディタにマークダウンをペーストした際、HTML挿入前に lexxy:insert-markdown イベントが発火するようになりました。イベントハンドラから変換後のDOMを同期的に操作することで、アプリケーション側がペースト内容を自由に制御できます。
背景
マークダウンペーストのカスタマイズ手段を提供するための試みは、過去に複数回行われていました。#738 では SoftBreakExtension による改行挙動の変更、#741 では configure() を通じた markdownPasteTransforms 配列の設定という形でアプローチが検討されていました。
markdownPasteTransforms 方式(#741)は、ミューテーション関数を configure() に渡す設計でした。しかし、この方式ではLexxy本体の設定APIを介する必要があり、インポートと設定の変更を伴う点でやや重い仕組みになっていました。今回の #757 はこれらの先行実装を置き換える形で採用されており、Web標準のカスタムイベント機構に乗ることで、よりシンプルなインターフェースを実現しています。
技術的な変更
マークダウンペースト処理の中核は src/editor/clipboard.js の #pasteMarkdown メソッドです。変更の本質は、これまで直接HTMLを挿入していた処理を、DOMを介したイベント発火に置き換えた点にあります。
変更前:
#pasteMarkdown(text) {
const html = marked(text)
this.contents.insertHtml(html, { tag: [ PASTE_TAG ] })
}
変更後:
#pasteMarkdown(text) {
const html = marked(text)
const doc = parseHtml(html)
const detail = Object.freeze({
markdown: text,
document: doc,
addBlockSpacing: () => addBlockSpacing(doc)
})
dispatch(this.editorElement, "lexxy:insert-markdown", detail)
this.contents.insertDOM(doc, { tag: PASTE_TAG })
}
marked() でHTMLに変換後、すぐに挿入するのではなく parseHtml() でDOM化し、そのDOMを event.detail.document として公開します。Object.freeze() によって detail オブジェクト自体への代入は禁止されていますが、document オブジェクトはミュータブルなので、ハンドラ内で自由にDOMを操作できます。
Contents クラスには新たに insertDOM メソッドが追加されました。従来の insertHtml は内部で parseHtml() を呼び出し、その結果を insertDOM に委譲する形にリファクタリングされています。
insertHtml(html, { tag } = {}) {
this.insertDOM(parseHtml(html), { tag })
}
insertDOM(doc, { tag } = {}) {
this.editor.update(() => {
const selection = $getSelection()
if (!$isRangeSelection(selection)) return
const nodes = $generateNodesFromDOM(this.editor, doc)
selection.insertNodes(nodes)
}, { tag })
}
addBlockSpacing ヘルパーは src/helpers/html_helper.js に追加されました。CSSセレクタ body > :not(h1, h2, h3, h4, h5, h6) + * を使い、見出し直後を除くトップレベルのブロック要素間に <p><br></p> を挿入します。
export function addBlockSpacing(doc) {
const blocks = doc.querySelectorAll("body > :not(h1, h2, h3, h4, h5, h6) + *")
for (const block of blocks) {
const spacer = doc.createElement("p")
spacer.appendChild(doc.createElement("br"))
block.before(spacer)
}
}
このセレクタは隣接兄弟結合子(+)と否定擬似クラス(:not)を組み合わせており、「前の要素が見出しでない場合のみスペーサーを挿入する」という条件を1つのクエリで表現しています。
設計判断
カスタムイベントによるフックが、設定APIによるトランスフォーム関数の登録(#741)に代わって採用されました。
イベントベースの設計にはいくつかの利点があります。Lexxyの configure() をインポートする必要がなく、addEventListener だけで拡張できるため、標準的なDOMプログラミングのパターンに従います。また、複数のハンドラを独立して登録・解除できるため、機能の組み合わせが柔軟です。
event.detail を Object.freeze() で凍結しつつ document をミュータブルに保つ設計は、「何を変更してよいか」を明確にする意図があります。markdown プロパティや addBlockSpacing 関数自体の上書きは防ぎつつ、DOM操作というユースケースに必要な変更の自由度は確保されています。また、非同期ハンドラへのサポートは明示的に切り捨てられており、イベント発火直後に insertDOM を呼ぶシンプルな同期フローが維持されています。
まとめ
本PRは、Lexxyのカスタムイベント体系にマークダウンペーストのフックポイントを追加した変更です。Web標準のイベントモデルに乗ることで設定APIへの依存を排除し、DOM操作という具体的な手段を通じて画像除去やブロックスペーシングといったユースケースをシンプルに解決できる設計になっています。