検索と置換のプラグインを作る

1. ブロックエディタの構造と課題

WordPress のブロックエディタは、各要素が「ブロック」という単位でカプセル化されています。

  • データの二重構造: エディタ上では React のステートとして管理されていますが、保存時には HTML コメント(“ など)を含む独自の形式でデータベースに格納されます。
  • HTML エンティティ: セキュリティとデータ整合性のため、&<&amp;&lt; として内部処理されます。

従来のプラグインは HTML 文字列を力技で書き換えるものが多く、ブロックの構造を破壊したり、不要なタグを残してしまうリスクがありました。

2. ゼロ・タグ レンダリング (Zero-tag Rendering)

本プラグインの最大の特徴は、エディタの HTML を一切汚さない CSS Custom Highlight API の採用です。

  • 従来の手法: 検索語を <mark> タグで囲むため、DOM 構造が変わり、リアクティブなエディタの動作を阻害する。
  • 本プラグインの手法: Range オブジェクトを使ってテキストの位置情報だけをブラウザに伝え、仮想的なレイヤーで色を塗ります。これにより、ソースコードには 1 文字の変更も加えずに、高速なハイライトを実現しています。

3. リアルタイム・プレシジョン (Real-time Precision)

ユーザーがタイピングするたびに、エディタ全体のテキストをスキャンします。これを支えるのが以下の設計です。

TreeWalker による精密スキャン

エディタ内の膨大な HTML 要素の中から、テキストノードだけを効率的に抽出する TreeWalker を使用しています。

  • ツールバーや設定パネルなどの「編集対象外」のエリアを排除し、純粋なコンテンツのみを対象にします。
  • 正規表現の保護: &amp; などの HTML エンティティを 1 つの「塊」として認識する独自の走査ロジックを実装。これにより、正規表現置換で & を書き換えても amp; が残ってしまうようなデータ破損を防いでいます。

4. プリシジョン・ナビゲーション (Precision Navigation)

見つけたマッチ箇所へ、迷わず辿り着くための設計です。

  • スマート・スクロール: getBoundingClientRect() を活用し、マッチした箇所が常に画面の中央に来るように、キャンバスのスクロール位置を計算・同期します。
  • 双方向インタラクション: サイドバーからのナビゲーションだけでなく、エディタ上のハイライトを直接クリックすることで、その箇所を「現在のターゲット」として選択できる直感的な操作感を実現しました。

5. インテリジェント・データ更新

置換を実行する際、プラグインは単なる「画面上の書き換え」ではなく、WordPress 内部の データストア (core/block-editor) と直接対話します。

  1. 階層構造の再帰処理: ネストされた「グループブロック」や「カラムブロック」の奥深くにある文字列まで、再帰的な探索(Deep Search)で確実に捕捉します。
  2. エンティティ・セーフ置換: 置換後の文字列は encodeForStorage を通じて適切にエンティティ化され、WordPress の標準仕様に則った形でデータストアへ書き戻されます。

まとめ:モダンなエディタに、モダンなツールを

LiveDraft Search & Replace は、WordPress エディタを「単なるテキストエリア」としてではなく、高度なオブジェクト構造として捉えることで完成しました。

「壊さない」「汚さない」「迷わせない」。 この 3 つの設計原則が、CSS Custom Highlight API や React の高度なステート管理と融合し、次世代の編集体験を提供します。

整理のポイント

  • DOM = 「今、画面で見えている偽物(表示用のコピー)」
  • データストア = 「ブラウザのメモリにある本物(保存される直前のデータ)」
  • データベース = 「サーバーにある保存版(お店を閉めた後の記録)」

あなたが「DOMのスキャン」と「データストアの更新」を使い分けているのは、「画面で見た通りに探し、データの根本を安全に直す」ためです。これこそが、WordPressというシステムの上で正しく動くための唯一の方法なんです。

まとめ

  • 検索: DOM(表示上の文字)を見て、ユーザーの期待通りに場所を特定する。
  • 表示: DOM(Range)に色を塗って、ユーザーに場所を教える。
  • 置換: 特定した場所を「データストア」への命令に変換して、保存可能なデータとして書き換える。

あなたのコードの役割分担

  • TreeWalker: 「住所(ノード)」を特定する。
  • Range + CSS Highlight: その「住所」に色を塗る。
  • getBoundingClientRect: その「住所」が今、画面のどこにあるかを計算して、スクロール(ジャンプ)させる。

あなたのコードで行われている「検索のステップ」を、正しい順番で整理します。


検索の3ステップ

ステップ1:TreeWalkerで「器(うつわ)」を集める

まず、TreeWalkerを起動します。このときTreeWalkerに教えるのは「検索ワード」ではなく、「どんな種類のノードを探してほしいか」という条件だけです。

  • あなたの設定: NodeFilter.SHOW_TEXT(テキストノードだけを見つけてきて!)

TreeWalkerは、エディター内のテキストノードを一つずつ順番に差し出してくれるだけです。

ステップ2:差し出されたテキストを「正規表現」で調べる

TreeWalkerが持ってきたテキストノード(例:"Hello World")に対して、初めて検索ワード(正規表現)をぶつけます。

JavaScript

// TreeWalkerが持ってきたノード 'n' に対して、
// ここで初めて検索ワード 'regex' を使ってチェックする
const match = regex.exec(n.nodeValue);

ステップ3:マッチした場所を記録する

もし検索ワードが見つかったら、そのノードと、その中の開始位置・終了位置を記録します。これが「ヒットした場所」の特定になります。

なぜTreeWalkerに直接渡さないのか?

TreeWalker自体には「文字を探す」という高度な機能は備わっていません。

  • TreeWalkerの仕事: 「次、3番目の段落のテキストノードです」「次、4番目のリストのテキストノードです」と案内すること。
  • 正規表現(RegExp)の仕事: 「その中に『WordPress』という単語はありますか?」と判定すること。

この2つを組み合わせることで、「どのブロックの、どのテキストノードの、何文字目にあるか」という極めて精緻な検索結果が得られるわけです。

正確に言うと、「検索(マッチ箇所の特定)」はDOMから行い、「置換(データの書き換え)」はデータストアに対して行うという、役割の使い分けをしています。

なぜ検索をデータストアではなくDOMから行うのか、その技術的な理由と仕組みを解説します。

1. 検索にDOM(TreeWalker)を使う理由

データストアにある「生データ(文字列)」だけを見て検索位置を特定するのは、実は非常に困難です。なぜなら、データストアの文字と、画面に見えている文字の間には「エディターによる加工」が入るからです。

  • HTMLエンティティ: データストアでは &&amp; ですが、画面(DOM)では & と表示されます。
  • 非表示要素: ブロックのメタ情報や隠しタグなど、ユーザーに見えない文字がデータストアには含まれています。

ユーザーは「画面に見えている文字」を基準に検索するため、DOMを直接スキャンするのが最も正確なのです。

2. 検索位置を特定する「2つの座標」

あなたのプラグインでは、マッチした場所を以下の2種類の情報で記録しています。

  1. 論理的な位置 (Range): TreeWalkerで見つけた「どのテキストノード」の「何文字目から何文字目」かという情報です。これは CSS Custom Highlight API で色を塗るために使われます。
  2. ブロックとの紐付け (clientId): 見つけたテキストノードから親を辿り、.wp-block が持つ data-block(clientId)を取得します。これは 置換を行う際、どのデータを書き換えるか特定する ために使われます。

3. 検索から置換への橋渡し

ここがこのプラグインの最もテクニカルな部分です。

  1. DOMで発見: 「このDOMノード(clientId: A)の5文字目にマッチした!」
  2. ストアで置換: 「clientId: A のブロック属性をストアから取得。その中にある文字列を置換して、ストアを更新!」

このように、「DOMで見つけた場所を、データストアの更新に翻訳する」という処理を行っています。

4. 課題:DOMとストアの「不一致」

ただし、DOMから位置を得ることには一つ弱点があります。それは、「DOM上の5文字目が、データストア(属性)内の5文字目と必ずしも一致しない」という点です。

例えば、属性内ではHTMLタグが含まれていたり、エスケープされていたりすると、文字数がズレます。

あなたのコードでは、これを解決するために localIndex(そのブロック内で何番目のマッチか) という概念を導入しています。

  • DOM側で「このブロックの3番目のマッチ」だと分かれば、データストア側でも「同じロジックで検索して3番目のマッチ」を置換すれば、位置がズレても正確に置換できるという仕組みです。

まとめ

  • 検索: DOM(表示上の文字)を見て、ユーザーの期待通りに場所を特定する。
  • 表示: DOM(Range)に色を塗って、ユーザーに場所を教える。
  • 置換: 特定した場所を「データストア」への命令に変換して、保存可能なデータとして書き換える。

この「表示(DOM)と実体(ストア)の橋渡し」がうまくいっているからこそ、Undoをしても壊れず、正確な置換ができるツールになっています。

1. データストアの基本構造

データストアは、主に以下の3つの要素で構成されています。

  • State (状態): 現在のブロック、選択範囲、編集履歴(Undo/Redo)などが保存されている生データ。
  • Selectors (セレクター): Stateから必要な情報だけを取り出すための関数。
    • あなたのコード:select('core/block-editor').getBlocks() など。
  • Actions / Dispatch (アクション): Stateを更新するための命令。
    • あなたのコード:updateBlockAttributes(clientId, attributes) など。

2. WordPressにおける階層構造

WordPressのデータストアは、役割ごとに「名前空間」という単位で分割されています。

名前空間役割あなたのプラグインでの使用例
core/editorエディター全体の基本設定ビジュアル/コードモードの判定(getEditorMode
core/block-editorブロックの配置や属性全ブロックの取得、個別ブロックの文字置換
core/dataデータストアの基盤useSelectuseDispatch の提供元

3. ブロックデータの持ち方(ツリー構造)

データストア内では、各ブロックは以下のようなオブジェクト(JSON)として管理されています。

JSON

{
  "clientId": "uuid-1234",
  "name": "core/paragraph",
  "attributes": {
    "content": "こんにちは、世界!",
    "fontSize": "large"
  },
  "innerBlocks": []
}
  • フラットではない: ブロックの中に innerBlocks(子ブロック)が含まれるツリー構造です。
  • あなたの実装の工夫: 置換処理の際に flatten 関数(allBlocks.flatMap(...))を使っているのは、このツリー構造を一旦バラバラにして、全てのテキストを漏れなくスキャンするためです。

4. DOMとデータストアの関係(ここが重要!)

あなたのプラグインが動くとき、2つの異なる世界が同期しています。

  1. データストア(真実のデータ):WordPressが「保存」したり「Undo」したりする際のマスターデータです。ここを書き換えないと、画面上で置換したように見えても保存されません。
  2. DOM(表示用のデータ):ブラウザが画面に描画している結果です。Custom Highlight APIはこの「表示用データ」の上に色を塗っています。

WordPressのデータストア内にあるブロックの属性(content など)は、基本的にHTMLエンティティ化された状態で収められています。

ただし、これは「エディターがどうデータを扱っているか」という非常に重要な部分に関わりますので、少し詳しく解説します。

1. データストア内での状態

WordPress(Gutenberg)のデータストア(core/block-editor)において、段落ブロックなどの content 属性は、ブラウザの innerHTML に近い形で保持されています。

  • 特殊文字の扱い:
    • &&amp;
    • <&lt;
    • >&gt;
    • "&quot;
  • 具体例: データストア上の生データは以下のようになっています。"content": "A &amp; B &lt; C &gt; &quot;D&quot;"

2. なぜエンティティ化されているのか?

これは「セキュリティ(XSS対策)」「データの整合性」のためです。

  1. 保存時の衝突回避: WordPressは最終的にこれらを “ というコメント形式のHTMLとしてデータベースに保存します。もし属性値の中に生の <> が混じっていると、HTMLのタグ構造とバッティングしてデータが壊れる可能性があります。
  2. Reactの仕様: データストアの値を画面に反映する際、Reactはデフォルトでエスケープ処理を行います。ストア側ですでにHTMLセーフな形になっていることで、意図しないスクリプトの実行(XSS)を防いでいます。

3. 【重要】あなたのプラグインへの影響

ここが、先ほどお話しした「DOM(TreeWalker)とデータストアのズレ」の正体です。

  • DOM(TreeWalker)が見ている世界: ブラウザがレンダリングした後の世界なので、&amp;&(1文字) としてカウントされます。
  • データストアが見ている世界:&amp;&amp;(5文字) としてカウントされます。

[!CAUTION] そのため、DOMで見つけた「5文字目から置換」という数値をそのままデータストアの content 文字列に適用すると、位置が大幅にズレて別の場所を破壊してしまうのです。

4. あなたのコードでの解決策(再確認)

あなたのプラグインで updateBlockAttributes を使って置換する際、単なる文字数(インデックス)指定ではなく、以下のような工夫が必要になります(あるいは既に実装されています)。

  1. RegExpによる再検索: データストアの文字列に対しても同じ正規表現をかけ、そのマッチした部分を置換する。
  2. サニタイズを考慮: 置換後の文字列も、WordPressの作法に合わせて適切にエンティティ化(またはデコード)された状態でストアに渡す。

まとめ

データストア内では 特殊文字はエスケープ(エンティティ化)されています。 このため、DOMで見つけた「見た目の文字」をデータストアの「生の文字」に反映させるには、この「エンティティ化による文字数の違い」を常に意識する必要があります。

コメント

  1. myblog より:

    テスト

タイトルとURLをコピーしました