[basecamp/lexxy] Lexical拡張機能のためのプラガブルなExtension APIの導入
Context: 拡張可能なエディタアーキテクチャの必要性
Lexxyは、Lexicalベースのリッチテキストエディタとして、多様な機能要件に応えるため拡張可能なアーキテクチャが求められていました。この変更では、下流ユーザーがLexicalの動作を簡単にカスタマイズし、ツールバーにマークアップを追加できる仕組みを導入しています。
従来は、エディタの振る舞いを拡張するためにはLexxyのコアコードを修正する必要がありましたが、このPRで導入されたExtension APIにより、プラガブルな形で機能を追加できるようになりました。
Technical Detail: Extension APIの実装
1. Extension基底クラスの設計
新しい LexxyExtension 基底クラスが追加され、拡張機能の標準インターフェースが定義されました。
export default class LexxyExtension {
#editorElement
constructor(editorElement) {
this.#editorElement = editorElement
}
get editorElement() {
return this.#editorElement
}
get editorConfig() {
return this.#editorElement.config
}
// optional: defaults to true
get enabled() {
return true
}
get lexicalExtension() {
return null
}
initializeToolbar(_lexxyToolbar) {
// 拡張ごとに実装
}
}
各拡張は以下の機能を持ちます:
-
enabledgetter: 拡張を有効にするかどうかを動的に判定 -
lexicalExtensiongetter: Lexicalエディタに渡す拡張定義を返す -
initializeToolbar()メソッド: ツールバーのカスタマイズロジックを実装
2. Extensions管理クラスの実装
Extensions クラスが、エディタごとに登録された拡張機能のライフサイクルを管理します。
export default class Extensions {
constructor(lexxyElement) {
this.lexxyElement = lexxyElement
this.enabledExtensions = this.#initializeExtensions()
}
get lexicalExtensions() {
return this.enabledExtensions.map(ext => ext.lexicalExtension).filter(Boolean)
}
initializeToolbars() {
if (this.#lexxyToolbar) {
this.enabledExtensions.forEach(ext => ext.initializeToobar(this.#lexxyToolbar))
}
}
#initializeExtensions() {
const extensionDefinitions = Lexxy.global.get("extensions")
return extensionDefinitions.map(
extension => new extension(this.lexxyElement)
).filter(extension => extension.enabled)
}
}
重要なポイント:
- 各エディタインスタンスごとに拡張がインスタンス化される
-
enabledがfalseを返す拡張は自動的にフィルタリングされる - Lexical拡張とツールバー初期化が分離されている
3. エディタへの統合
エディタ要素のライフサイクル内で拡張機能が初期化され、Lexicalエディタに組み込まれます。
connectedCallback() {
this.id ??= generateDomId("lexxy-editor")
this.config = new Configuration(this)
this.extensions = new Extensions(this)
this.highlighter = new Highlighter(this)
this.editor = this.#createEditor()
// ...
}
get #lexicalExtensions() {
const extensions = [ ]
const richTextExtensions = [
this.highlighter.lexicalExtension,
TrixContentExtension
]
if (this.supportsRichText) {
extensions.push(...richTextExtensions)
}
extensions.push(...this.extensions.lexicalExtensions)
return extensions
}
4. グローバル設定への登録
拡張機能は、グローバル設定の extensions 配列に登録します。
const global = new Configuration({
attachmentTagName: "action-text-attachment",
attachmentContentTypeNamespace: "actiontext",
authenticatedUploads: false,
extensions: []
})
使用例:
import * as Lexxy from "@37signals/lexxy"
class MyLexxyExtension extends Lexxy.Extension {
get enabled() {
return this.editorElement.supportsRichText
}
get lexicalExtension() {
return defineExtension({
// Lexical extension definition
})
}
initializeToolbar(lexxyToolbar) {
// Custom toolbar logic
}
}
Lexxy.configure({
global: {
extensions: [ MyLexxyExtension ]
},
default: {
my_extension: {
enableCoolFeature: true
}
}
})
アーキテクチャ上の利点
モジュール分離の改善
この変更では、コードの整理も行われています:
-
sanitization_helper.js: HTML sanitization機能を独立したモジュールに分離 -
nodes.js: Lexxyのノードクラスをエクスポート可能に -
code_highlighting_helper.js:highlightAllをhighlightCodeにリネーム(後方互換性を保持)
npm パッケージのサブモジュールサポート
package.json の exports フィールドが拡張され、ヘルパー関数を個別にインポート可能になりました。
"exports": {
".": "./dist/lexxy.esm.js",
"./helpers": "./dist/lexxy_helpers.esm.js"
}
使用例:
// メインバンドルから分離してヘルパーのみインポート
import { highlightCode } from "@37signals/lexxy/helpers"
Rollupの設定では、manualChunks を使用してヘルパー関数を専用のチャンクに分離しています。
manualChunks: (id) => {
if (helperChunks.some(file => id.includes(file))) return "lexxy_helpers"
return null
}
まとめ
この変更により、Lexxyは以下の点で大きく改善されました:
- 拡張可能性: プラガブルなExtension APIにより、コア変更なしで機能追加が可能
-
柔軟性:
enabledgetterによる動的な拡張の有効化/無効化 - モジュール性: 機能ごとのコード分離と、npmパッケージのサブモジュールサポート
-
後方互換性: 既存の
highlightAll関数は deprecated扱いながらもエクスポートを維持
注意点として、PRのノートにもある通り、このAPIは実験的であり、Lexical本体のような完全な依存性管理システムは提供していません。シンプルな拡張メカニズムとして設計されており、今後のフィードバックに基づいて進化する可能性があります。