ネイティブモバイル向けStrada Bridgeサポートをツールバー・選択・添付ファイルに追加
LexxyのエディタにStrada Bridgeを介したネイティブモバイルサポートが追加された。iOS/Androidアプリが独自のツールバーUIを描画しながら、エディタ側がコンテンツ管理を担う分離アーキテクチャを実現する。
背景
LexxyはWebベースのリッチテキストエディタだが、Strada Bridgeを使用するネイティブアプリがWebViewを組み込む場合、ネイティブ側のツールバーUIとWeb側のエディタ状態を同期する仕組みが必要になる。これまでWebエディタは状態変化をネイティブ層に伝える手段を持っておらず、ネイティブアプリは選択状態・フォーマット状態・ハイライトカラーパレットなどを認識できなかった。
このPRはその橋渡しとなる「アダプタ層」と各種イベント・コマンドを追加し、ネイティブ側のStradaコントローラが接続できる土台を提供する。
技術的な変更
アダプタパターンによる環境抽象化
ネイティブ連携の中核として BrowserAdapter と NativeAdapter の2クラスが導入された。エディタは常に editorElement.adapter を通じてイベントをディスパッチし、実行環境を意識しない設計になっている。
BrowserAdapter(src/editor/adapters/browser_adapter.js)はすべてのメソッドをno-opとして実装したデフォルト実装であり、ブラウザ環境では何も起こらない。NativeAdapter(src/editor/adapters/native_adapter.js)はネイティブアプリ向けの実装で、エディタ要素に対して lexxy:attributes-change や lexxy:editor-initialized などのカスタムイベントをディスパッチする。エディタ要素の registerAdapter() を呼ぶことで既定の BrowserAdapter を置き換えられる。
この切り替えにより、同一のエディタコードがブラウザとネイティブWebViewの両方で動作する。
エディタ状態のネイティブへの通知
lexxy:attributes-change イベントがエディタの状態更新・フォーカス変化ごとに発火し、ネイティブツールバーが現在の選択状態を反映できる。イベントのペイロードには bold・italic・strikethrough・code・highlight・link・quote・heading・unordered-list・ordered-list・undo・redo の各属性が含まれ、それぞれ active(現在の選択に適用済みか)と enabled(操作可能か)の2プロパティを持つ。
ハイライトカラーパレットは lexxy:editor-initialized イベントで一度だけ送信される。src/helpers/format_helper.js に追加された getHighlightStyles() 関数がCSSカスタムプロパティを解決し、var(--highlight-N) 形式の値をRGB値に展開してネイティブ側に渡す。これによりネイティブアプリはCSSを解釈せずにカラースウォッチを表示できる。
選択のfreeze/thaw
ネイティブダイアログ(リンク編集など)がモーダルを開く間、ユーザーの選択範囲を保持するための freezeSelection() / thawSelection() がエディタ要素に追加された。
freeze() は editorContentElement.contentEditable を "false" に設定して編集を無効化し、選択がリンクノード内にある場合は frozenLinkKey にそのノードキーを保持する。thaw() は contentEditable を "true" に戻す。dispatchUnlink() は adapter.unlinkFrozenNode() を呼び出し、アダプタがフローズンリンクキーを対象にアンリンクを実行できる仕組みになっている。
変更前の dispatchUnlink() は単に #toggleLink(null) を呼ぶだけだったが、変更後はアダプタに処理を委譲する分岐が追加されている:
変更前:
dispatchUnlink() {
this.#toggleLink(null)
}
変更後:
dispatchUnlink() {
this.editor.update(() => {
// Let adapters signal whether unlink should target a frozen link key.
if (this.editorElement.adapter.unlinkFrozenNode?.()) {
return
}
$toggleLink(null)
})
}
コード言語ピッカーのネイティブ委譲
CodeLanguagePicker が mousedown イベントを補足し、lexxy:code-language-picker-open イベントをディスパッチするようになった。ネイティブアプリはこのイベントを受け取って独自のピッカーUIを表示し、選択後に新コマンド setCodeLanguage を呼び出してコードブロックの言語を適用する。
CommandDispatcher に追加された dispatchSetCodeLanguage(language) は、選択がコードブロック内にある場合に限り CodeNode.setLanguage() を呼び出す。また CodeLanguagePicker のイベントリスナー管理に AbortController が導入され、dispose() 時に abort() でリスナーを一括解除できるようになった。
Bridge管理の添付ファイルアップロード
Contents クラスに追加された insertPendingAttachment(file) は、uploadUrl: null で ActionTextAttachmentUploadNode を作成し、DirectUploadをスキップする。戻り値のハンドルオブジェクトは以下の3メソッドを持ち、ネイティブ側がアップロードライフサイクルを制御する:
-
setAttributes(blob): アップロード完了後にノードを通常の添付ファイルノードへ置き換える -
setUploadProgress(progress): 進捗バーを更新する -
remove(): ノードをエディタから削除する
#startUploadIfNeeded() に if (!this.uploadUrl) return の早期リターンが追加され、uploadUrl が null の場合はDirectUploadを開始しない。また createAttachmentFigure() がオプションの previewable 引数を受け取るよう変更され、bridge管理アップロードでは canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null の条件でファイルアイコン表示に固定される。
その他の変更
NativeAdapter は src/index.js からエクスポートされ、外部のStradaコントローラから import { NativeAdapter } from "lexxy" でインポートできる。eslint.config.js には AbortController: "readonly" が追加され、CodeLanguagePicker での使用を許可している。
設計判断
アダプタパターンの採用により、エディタコア(editor.js・command_dispatcher.js)はネイティブ実装を直接参照しない。editorElement.adapter を介した間接呼び出しとすることで、ブラウザ環境でネイティブコードが実行されることを防ぎつつ、将来のアダプタ追加も容易にしている。
freeze/thaw機構は contentEditable の切り替えという最小限の実装に留めており、Lexicalのエディタ状態そのものを操作しない。ネイティブダイアログの表示中もエディタの内部状態は一貫して保たれる。
bridge管理アップロードのハンドルオブジェクトはクロージャ経由でノードキーを保持し、イベント系ではなくAPIコール型のインターフェースを採用している。ネイティブ側がプッシュ型で進捗を通知できるため、WebView間のメッセージングとの相性が良い設計といえる。
すべての機能に対応するテストが test/javascript/native/ 配下に追加されており、アダプタ登録・属性変化イベント・freeze/thaw・コード言語コマンド・pending添付ファイルの各シナリオが網羅されている。
まとめ
このPRはLexxyに「ネイティブアプリが上に乗れるプラットフォーム」としての拡張点を追加した変更である。アダプタパターンによりブラウザ動作を一切損なわずにネイティブ連携APIを追加しており、Strada Bridgeコントローラとネイティブ実装を組み合わせることで、WebViewベースのリッチテキストエディタとネイティブUIの協調動作が実現できる。