ツールバーの大規模リワーク:見出しコマンドの明示化とブロックフォーマットの刷新

basecamp/lexxy

Lexxyのツールバーが大幅に再設計され、UIの整理と共に内部実装も刷新された。単一の「見出しサイクル」コマンドを個別の明示的コマンドへ置き換え、Lexicalの組み込みAPIを活用してカスタムのノード操作ロジック約400行を削除した。

背景

これまでのツールバーには、見出しレベルを順番に切り替える rotateHeadingFormat コマンドが1つあるだけだった。現在どの見出しレベルが適用されているかをツールバー側で区別して表示する手段がなく、任意の見出しレベルに直接ジャンプする方法もなかった。

同様に、アップロードボタンは upload という名前の単一ボタンのみで、ファイルアップロードと画像アップロードの区別がUIから読み取れない状態だった。ブロックフォーマットの適用は contents.js 内のカスタムノード操作で実装されており、Lexicalが提供する $setBlocksType APIを使わず独自のラッピングロジックが蓄積していた。

技術的な変更

見出しコマンドの明示化

rotateHeadingFormat という単一サイクルコマンドが廃止され、4つの明示的なコマンドに置き換えられた。command_dispatcher.js のCOMMANDS定数とdispatchメソッドが更新されている。

変更前:

const COMMANDS = [
  "bold",
  "italic",
  "strikethrough",
  "rotateHeadingFormat",
  // ...
]

変更後:

const COMMANDS = [
  "bold",
  "italic",
  "strikethrough",
  "underline",
  // ...
  "setFormatHeadingLarge",
  "setFormatHeadingMedium",
  "setFormatHeadingSmall",
  "setFormatParagraph",
  // ...
]

合わせて selection.jsheadingTag(h2/h3/h4)が getFormat() の戻り値に追加され、ツールバーが現在のアクティブな見出しレベルをハイライト表示できるようになった。#getNearestHeadingNode メソッドがトップレベル要素からの検索とその祖先を辿る探索を組み合わせて実装されている。

$setBlocksType によるブロックフォーマットの置き換え

contents.js から insertNodeWrappingEachSelectedLinetoggleNodeWrappingAllSelectedLines などのカスタムロジックが削除され、Lexicalの $setBlocksType に一本化された。

変更前(カスタムラッピングロジックの例):

insertNodeWrappingEachSelectedLine(newNodeFn) {
  this.editor.update(() => {
    const selection = $getSelection()
    if (!$isRangeSelection(selection)) return

    const selectedNodes = selection.extract()
    selectedNodes.forEach((node) => {
      const parent = node.getParent()
      if (!parent) { return }

      const topLevelElement = node.getTopLevelElementOrThrow()
      const wrappingNode = newNodeFn()
      wrappingNode.append(...topLevelElement.getChildren())
      topLevelElement.replace(wrappingNode)
    })
  })
}

変更後($setBlocksType を使用):

applyParagraphFormat() {
  const selection = $getSelection()
  if (!$isRangeSelection(selection)) return

  $setBlocksType(selection, () => $createParagraphNode())
}

applyHeadingFormat(tag) {
  // $setBlocksType を使用
}

この変更で $isRootOrShadowRoot$createHeadingNode$createQuoteNode$isCodeNode といった多数のインポートが command_dispatcher.js から削除され、代わりに contents.js 側に適切に集約された。差分上の削除は479行に対して追加が105行となっており、正味374行のコード削減となっている。

アップロードボタンの分離と toolbar.upload 設定

アップロードボタンが name="upload" の単一ボタンから name="image"name="file" の2ボタンに分割された。デフォルトではどちらも表示されるが、toolbar.upload 設定で制御できる。

デフォルト設定が以下のように変更された:

toolbar: {
  upload: "both"
},

受け付ける値は "file""image""both" の3種類で、CSS側では data-upload 属性によって対応するボタンを display: none にする方式が採用されている。

toolbar.jsconfigure メソッドがこの仕組みを担い、設定オブジェクトのエントリをそのまま data-* 属性としてツールバー要素に付与する汎用的な実装になっている:

configure(config) {
  if (typeof config === "object" && config !== null) {
    for (const [ button, value ] of Object.entries(config)) {
      this.setAttribute(`data-${button}`, value)
    }
  }
}

toolbar 設定の型変更への対応

config.get("toolbar") の返り値がオブジェクトになり得るようになったため、editor.js#findOrCreateDefaultToolbar も更新された。

変更前:

const toolbarId = this.config.get("toolbar")
if (toolbarId && toolbarId !== true) {
  return document.getElementById(toolbarId)
}

変更後:

const toolbarConfig = this.config.get("toolbar")
if (typeof toolbarConfig === "string") {
  return document.getElementById(toolbarConfig)
}

toolbarId !== true という比較が typeof toolbarConfig === "string" という型チェックに変わり、オブジェクトが渡された場合は #createDefaultToolbar が呼ばれてデフォルトツールバーが生成される。また #hasToolbar のゲッターも !! による明示的なboolean変換が加わっている。

アンダーライン対応

underline コマンドが command_dispatcher.js に追加され、FORMAT_TEXT_COMMAND"underline" を渡す dispatchUnderline メソッドが実装された。selection.jsgetFormat() にも isUnderline フラグが追加され、CSSでは strikethroughunderline が同時に適用された場合のスタイル(line-through underline)も定義された。

テストの更新

ブラウザテストで page.getByRole("button", { name: "..." }) によるロール指定の代わりに clickFormatButtonclickListsButton ヘルパーが導入された。フォーマットとリストが <details> ドロップダウン内に移動したことに対応したもので、ドロップダウンをプログラム的に開いてからボタンをクリックする実装になっている。

設計判断

$setBlocksType への一本化が最も大きな設計判断といえる。Lexicalが提供する組み込みAPIを使うことで、選択範囲にわたるノードのラッピング・アンラッピングをフレームワーク側に委譲し、自前で管理していた複雑なエッジケース処理を除去している。削除されたコード量の大きさが、以前の実装がいかに多くのケースを手動で扱う必要があったかを示している。

toolbar.upload の設計では、設定キーをそのまま data-* 属性に変換する汎用的な configure メソッドが採用されている。この方式は将来的に upload 以外のボタン設定を追加する場合にも同じ仕組みで対応できる拡張性を持つ。data-upload="file" のような属性値でCSS側がボタンの表示を制御する構成は、JavaScriptを介さずに表示制御が完結する点でも明快な設計である。

見出しコマンドの明示化では、サイクル方式から個別コマンド方式への移行と同時に、ツールバーにドロップダウンUIが導入されている。ユーザーは現在のアクティブな見出しレベルをドロップダウン内でハイライト表示により確認でき、かつ任意のレベルに直接ジャンプできるようになった。この変更は headingTag の公開と表裏一体であり、状態の可視化と操作の直接指定が同時に解決されている。

まとめ

本PRはUIの整理を起点に、Lexical組み込みAPIへの委譲によるコード削減、コマンド設計の明示化、設定インターフェースの型安全な拡張という複数の改善を同時に達成している。特に contents.js の374行削減は、フレームワーク提供のAPIを積極的に活用することで自前のノード操作ロジックを排除できることを示す好例といえる。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
6e439f26

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

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
2回 (改善を経て承認)
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文(総論)→背景・技術詳細・設計判断(各論)→まとめ(結論)の3部構成が明確に適用されており、理想的な記事構成です。

カスタムMarkdown構文 ✓ PASS

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

ファイル名付きシンタックスハイライト(例: ```javascript:src/editor/command_dispatcher.js)やPR番号のリンク記法(例: [PR #891](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

Lexicalフレームワークに関する知識を前提とした内容であり、専門知識を持つエンジニアという対象読者に適合しています。

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

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

各セクション・各パラグラフが要点から始まる構成になっており、非常に読みやすいです。1段落1トピックの原則も守られています。

Diff内容との照合 ✓ PASS

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

記事で引用されているコードブロックは、提供されたDiffの内容と完全に一致しており、正確です。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「$setBlocksType」「rotateHeadingFormat」「ノード操作ロジック」などの技術用語が、PRの文脈に沿って正確かつ適切に使用されています。

説明の技術的正確性 ✓ PASS

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

コード削減行数(正味374行)やLexicalの組み込みAPIへの移行など、技術的な説明がPR情報とDiffによって正確に裏付けられています。

事実の突合 ✓ PASS

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

記事内のすべての主張がPRのDescriptionやDiff内容に基づいており、根拠のない推測や創作(ハルシネーション)は見られません。

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

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

PR番号(#891)、コード削減行数(削除479行、追加105行、正味374行)など、記事に含まれる数値や固有名詞はすべて正確です。

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

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

記事のタイトルはPRの「Toolbar Rework」という主題を的確に反映し、具体的な変更点(見出しコマンド、ブロックフォーマット)を要約しており、秀逸です。

外部知識の正確性 ✓ PASS

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

PR情報に含まれないバージョンサポート状況やリリース日程などの外部知識は記載されておらず、事実に基づいた内容となっています。

時間表現の正確性 ✓ PASS

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

「これまでのツールバーには〜」や「〜が廃止され〜」といった時間表現が、PRによる変更の前後関係を正しく示しています。