ツールバーの大規模リワーク:見出しコマンドの明示化とブロックフォーマットの刷新
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.js で headingTag(h2/h3/h4)が getFormat() の戻り値に追加され、ツールバーが現在のアクティブな見出しレベルをハイライト表示できるようになった。#getNearestHeadingNode メソッドがトップレベル要素からの検索とその祖先を辿る探索を組み合わせて実装されている。
$setBlocksType によるブロックフォーマットの置き換え
contents.js から insertNodeWrappingEachSelectedLine・toggleNodeWrappingAllSelectedLines などのカスタムロジックが削除され、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.js の configure メソッドがこの仕組みを担い、設定オブジェクトのエントリをそのまま 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.js の getFormat() にも isUnderline フラグが追加され、CSSでは strikethrough と underline が同時に適用された場合のスタイル(line-through underline)も定義された。
テストの更新
ブラウザテストで page.getByRole("button", { name: "..." }) によるロール指定の代わりに clickFormatButton と clickListsButton ヘルパーが導入された。フォーマットとリストが <details> ドロップダウン内に移動したことに対応したもので、ドロップダウンをプログラム的に開いてからボタンをクリックする実装になっている。
設計判断
$setBlocksType への一本化が最も大きな設計判断といえる。Lexicalが提供する組み込みAPIを使うことで、選択範囲にわたるノードのラッピング・アンラッピングをフレームワーク側に委譲し、自前で管理していた複雑なエッジケース処理を除去している。削除されたコード量の大きさが、以前の実装がいかに多くのケースを手動で扱う必要があったかを示している。
toolbar.upload の設計では、設定キーをそのまま data-* 属性に変換する汎用的な configure メソッドが採用されている。この方式は将来的に upload 以外のボタン設定を追加する場合にも同じ仕組みで対応できる拡張性を持つ。data-upload="file" のような属性値でCSS側がボタンの表示を制御する構成は、JavaScriptを介さずに表示制御が完結する点でも明快な設計である。
見出しコマンドの明示化では、サイクル方式から個別コマンド方式への移行と同時に、ツールバーにドロップダウンUIが導入されている。ユーザーは現在のアクティブな見出しレベルをドロップダウン内でハイライト表示により確認でき、かつ任意のレベルに直接ジャンプできるようになった。この変更は headingTag の公開と表裏一体であり、状態の可視化と操作の直接指定が同時に解決されている。
まとめ
本PRはUIの整理を起点に、Lexical組み込みAPIへの委譲によるコード削減、コマンド設計の明示化、設定インターフェースの型安全な拡張という複数の改善を同時に達成している。特に contents.js の374行削減は、フレームワーク提供のAPIを積極的に活用することで自前のノード操作ロジックを排除できることを示す好例といえる。