マークダウンペースト時に `lexxy:insert-markdown` イベントを発火

basecamp/lexxy

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.detailObject.freeze() で凍結しつつ document をミュータブルに保つ設計は、「何を変更してよいか」を明確にする意図があります。markdown プロパティや addBlockSpacing 関数自体の上書きは防ぎつつ、DOM操作というユースケースに必要な変更の自由度は確保されています。また、非同期ハンドラへのサポートは明示的に切り捨てられており、イベント発火直後に insertDOM を呼ぶシンプルな同期フローが維持されています。

まとめ

本PRは、Lexxyのカスタムイベント体系にマークダウンペーストのフックポイントを追加した変更です。Web標準のイベントモデルに乗ることで設定APIへの依存を排除し、DOM操作という具体的な手段を通じて画像除去やブロックスペーシングといったユースケースをシンプルに解決できる設計になっています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
44a157d4

この記事は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:src/editor/clipboard.js)やPR番号のリンク記法(例: [#757](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

DOM、カスタムイベント、ミュータブルといった技術用語を前提知識として使用しており、対象読者であるエンジニアに適した技術レベルで記述されています。

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

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

各セクションが総論→各論の構成で、かつ各段落がトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が徹底されており、可読性が非常に高いです。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロックは、提供されたDiff情報と正確に一致しています。ファイルパスの指定も正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「カスタムイベント」「同期フロー」「隣接兄弟結合子」など、使用されている技術用語は文脈に適しており、正確です。

説明の技術的正確性 ✓ PASS

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

「Object.freeze()」の意図や「addBlockSpacing」のCSSセレクタの挙動など、技術的な説明は正確かつ論理的です。

事実の突合 ✓ PASS

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

過去のPR(#738, #741)との関連性を含め、記事内のすべての主張がPR Descriptionの「This supersedes previous attempts...」という記述で裏付けられており、ハルシネーションはありません。

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

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

PR番号(#757, #738, #741)やその他の固有名詞は、提供された情報と完全に一致しています。

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

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

記事のタイトルはPRのタイトル「Fire `lexxy:insert-markdown` event on markdown paste」を正確に反映しており、内容との齟齬はありません。

外部知識の正確性 ✓ PASS

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

記事には、バージョン情報やリリース予定など、PRに記載のない外部知識は含まれていません。

時間表現の正確性 ✓ PASS

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

PR Descriptionの「now fires」という表現に対し、記事で「〜ようになりました」と記述されており、時間表現は正確です。