ネイティブモバイル向けStrada Bridgeサポートをツールバー・選択・添付ファイルに追加

basecamp/lexxy

LexxyのエディタにStrada Bridgeを介したネイティブモバイルサポートが追加された。iOS/Androidアプリが独自のツールバーUIを描画しながら、エディタ側がコンテンツ管理を担う分離アーキテクチャを実現する。

背景

LexxyはWebベースのリッチテキストエディタだが、Strada Bridgeを使用するネイティブアプリがWebViewを組み込む場合、ネイティブ側のツールバーUIとWeb側のエディタ状態を同期する仕組みが必要になる。これまでWebエディタは状態変化をネイティブ層に伝える手段を持っておらず、ネイティブアプリは選択状態・フォーマット状態・ハイライトカラーパレットなどを認識できなかった。

このPRはその橋渡しとなる「アダプタ層」と各種イベント・コマンドを追加し、ネイティブ側のStradaコントローラが接続できる土台を提供する。

技術的な変更

アダプタパターンによる環境抽象化

ネイティブ連携の中核として BrowserAdapterNativeAdapter の2クラスが導入された。エディタは常に editorElement.adapter を通じてイベントをディスパッチし、実行環境を意識しない設計になっている。

BrowserAdaptersrc/editor/adapters/browser_adapter.js)はすべてのメソッドをno-opとして実装したデフォルト実装であり、ブラウザ環境では何も起こらない。NativeAdaptersrc/editor/adapters/native_adapter.js)はネイティブアプリ向けの実装で、エディタ要素に対して lexxy:attributes-changelexxy:editor-initialized などのカスタムイベントをディスパッチする。エディタ要素の registerAdapter() を呼ぶことで既定の BrowserAdapter を置き換えられる。

この切り替えにより、同一のエディタコードがブラウザとネイティブWebViewの両方で動作する。

エディタ状態のネイティブへの通知

lexxy:attributes-change イベントがエディタの状態更新・フォーカス変化ごとに発火し、ネイティブツールバーが現在の選択状態を反映できる。イベントのペイロードには bolditalicstrikethroughcodehighlightlinkquoteheadingunordered-listordered-listundoredo の各属性が含まれ、それぞれ 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)
  })
}

コード言語ピッカーのネイティブ委譲

CodeLanguagePickermousedown イベントを補足し、lexxy:code-language-picker-open イベントをディスパッチするようになった。ネイティブアプリはこのイベントを受け取って独自のピッカーUIを表示し、選択後に新コマンド setCodeLanguage を呼び出してコードブロックの言語を適用する。

CommandDispatcher に追加された dispatchSetCodeLanguage(language) は、選択がコードブロック内にある場合に限り CodeNode.setLanguage() を呼び出す。また CodeLanguagePicker のイベントリスナー管理に AbortController が導入され、dispose() 時に abort() でリスナーを一括解除できるようになった。

Bridge管理の添付ファイルアップロード

Contents クラスに追加された insertPendingAttachment(file) は、uploadUrl: nullActionTextAttachmentUploadNode を作成し、DirectUploadをスキップする。戻り値のハンドルオブジェクトは以下の3メソッドを持ち、ネイティブ側がアップロードライフサイクルを制御する:

  • setAttributes(blob): アップロード完了後にノードを通常の添付ファイルノードへ置き換える
  • setUploadProgress(progress): 進捗バーを更新する
  • remove(): ノードをエディタから削除する

#startUploadIfNeeded()if (!this.uploadUrl) return の早期リターンが追加され、uploadUrlnull の場合はDirectUploadを開始しない。また createAttachmentFigure() がオプションの previewable 引数を受け取るよう変更され、bridge管理アップロードでは canPreviewFile = this.isPreviewableAttachment && this.uploadUrl != null の条件でファイルアイコン表示に固定される。

その他の変更

NativeAdaptersrc/index.js からエクスポートされ、外部のStradaコントローラから import { NativeAdapter } from "lexxy" でインポートできる。eslint.config.js には AbortController: "readonly" が追加され、CodeLanguagePicker での使用を許可している。

設計判断

アダプタパターンの採用により、エディタコア(editor.jscommand_dispatcher.js)はネイティブ実装を直接参照しない。editorElement.adapter を介した間接呼び出しとすることで、ブラウザ環境でネイティブコードが実行されることを防ぎつつ、将来のアダプタ追加も容易にしている。

freeze/thaw機構は contentEditable の切り替えという最小限の実装に留めており、Lexicalのエディタ状態そのものを操作しない。ネイティブダイアログの表示中もエディタの内部状態は一貫して保たれる。

bridge管理アップロードのハンドルオブジェクトはクロージャ経由でノードキーを保持し、イベント系ではなくAPIコール型のインターフェースを採用している。ネイティブ側がプッシュ型で進捗を通知できるため、WebView間のメッセージングとの相性が良い設計といえる。

すべての機能に対応するテストが test/javascript/native/ 配下に追加されており、アダプタ登録・属性変化イベント・freeze/thaw・コード言語コマンド・pending添付ファイルの各シナリオが網羅されている。

まとめ

このPRはLexxyに「ネイティブアプリが上に乗れるプラットフォーム」としての拡張点を追加した変更である。アダプタパターンによりブラウザ動作を一切損なわずにネイティブ連携APIを追加しており、Strada Bridgeコントローラとネイティブ実装を組み合わせることで、WebViewベースのリッチテキストエディタとネイティブUIの協調動作が実現できる。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
6048930e

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

「総論→各論→結論」の構成が明確です。リード文、背景、技術的な変更、設計判断、まとめの各要素がすべて含まれており、理想的な構成です。

カスタムMarkdown構文 ✓ PASS

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

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

対象読者への適合性 ✓ PASS

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

アダプタパターン、WebView、Lexicalの内部APIなど、専門知識を持つエンジニアを対象とした適切な技術レベルと表現で記述されています。

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

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

各セクションが総論・各論・結論で構成され、各段落はトピックセンテンスで始まるなど、パラグラフ・ライティングの原則が守られており、高い可読性を実現しています。

Diff内容との照合 ✓ PASS

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

記事内で引用されているコードブロックや技術的な説明は、提供されたDiffの内容と正確に一致しています。`dispatchUnlink`の変更点も的確に捉えられています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「Strada Bridge」「アダプタパターン」「freeze/thaw」「no-op」などの技術用語が、文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

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

アダプタパターンの役割、イベントによる状態通知の仕組み、添付ファイル管理のライフサイクルなど、技術的な説明はDiffの内容に裏付けられており、論理的で正確です。

事実の突合 ✓ PASS

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

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

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

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

PR番号(#737)や、`lexxy:attributes-change`といったイベント名などの固有名詞はすべて正確に記載されています。

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

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

記事のタイトル「ネイティブモバイル向けStrada Bridgeサポートをツールバー・選択・添付ファイルに追加」は、PRの主題と完全に一致しています。

外部知識の正確性 ✓ PASS

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

記事の内容はPRで提供された情報に限定されており、バージョンサポート状況などの外部知識の追記はありません。

時間表現の正確性 ✓ PASS

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

「〜が追加された」「〜ようになった」といった過去・完了形の表現が使われており、変更が既にマージされたPRの内容を正しく反映しています。