web-dev-qa-db-ja.com

JavaScriptを使用してノードのテキストの一部をラップする方法

解決しなければならない困難な問題があります。入力として正規表現を使用するスクリプトを作成しています。次に、このスクリプトは、ドキュメント内のこの正規表現に一致するものをすべて検索し、各一致を独自の<span>要素でラップします。難しいのは、テキストがフォーマットされたhtmlドキュメントであるため、私のスクリプトはDOMをナビゲートし、一度に複数のテキストノードに正規表現を適用し、必要に応じてテキストノードを分割する必要がある場所を特定する必要があることです。

たとえば、大文字で始まりピリオドで終わる完全な文をキャプチャする正規表現では、このドキュメントは次のようになります。

<p>
  <b>HTML</b> is a language used to make <b>websites.</b>
  It was developed by <i>CERN</i> employees in the early 90s.
<p>

これに変わります:

<p>
  <span><b>HTML</b> is a language used to make <b>websites.</b></span>
  <span>It was developed by <i>CERN</i> employees in the early 90s.</span>
<p>

次に、スクリプトは作成されたすべてのスパンのリストを返します。

すべてのテキストノードを見つけて、ドキュメント全体の位置と深度とともにリストに格納するコードがすでにあります。私を助けるためにそのコードを本当に理解する必要はなく、その再帰的な構造は少し混乱する可能性があります。 T 最初の部分は、どの要素をスパン内に含める必要があるかを理解する方法がわかりません。

function SmartNode(node, depth, start) {
  this.node = node;
  this.depth = depth;
  this.start = start;
}


function findTextNodes(node, depth, start) {
  var list = [];
  var start = start || 0;
  depth = (typeof depth !== "undefined" ? depth : -1);

  if(node.nodeType === Node.TEXT_NODE) {
    list.Push(new SmartNode(node, depth, start));
  } else {
    for(var i=0; i < node.childNodes.length; ++i) {
      list = list.concat(findTextNodes(node.childNodes[i], depth+1, start));
      if(list.length) start += list[list.length-1].node.nodeValue.length;
    }
  }

  return list;
}

すべてのドキュメントから文字列を作成し、それを介して正規表現を実行し、リストを使用して、どのノードが魔女の正規表現の一致に対応するかを見つけ、それに応じてテキストノードを分割します。

しかし、次のようなドキュメントがあると問題が発生します。

<p>
  This program is <a href="beta.html">not stable yet. Do not use this in production yet.</a>
</p>

<a>タグの外側で始まり、その内側で終わる文があります。スクリプトでそのリンクを2つのタグに分割したくありません。より複雑なドキュメントでは、ページを台無しにしてしまう可能性があります。コードは2つの文を一緒にラップすることができます。

<p>
  <span>This program is <a href="beta.html">not stable yet. Do not use this in production yet.</a></span>
</p>

または、各パーツを独自の要素でラップするだけです。

<p>
  <span>This program is </span>
  <a href="beta.html">
    <span>not stable yet.</span>
    <span>Do not use this in production yet.</span>
  </a>
</p>

何をすべきかを指定するパラメータがあるかもしれません。 不可能なカットがいつ起こりそうかを理解する方法と、それからどのように回復するのか、私にはわかりません。

このような子要素内に空白があると、別の問題が発生します

<p>This is a <b>sentence. </b></p>

技術的には、正規表現の一致はピリオドの直後、<b>タグの終了前に終了します。ただし、スペースを一致の一部と見なして、次のようにラップする方がはるかに適切です。

<p><span>This is a <b>sentence. </b></span></p>

これより:

<p><span>This is a </span><b><span>sentence.</span> </b></p>

しかし、それは小さな問題です。結局のところ、余分な空白を正規表現に含めることを許可することができます。

これは「やってみよう」という質問のように聞こえるかもしれませんが、SOで日常的に見られるような簡単な質問ではありませんが、私はこれにこだわっていますこの問題を解決することが最後の障害です。別のSEサイトがこの質問に最適であると思われる場合は、リダイレクトしてください。

47
Domino

これに対処するには2つの方法があります。

以下が正確にあなたのニーズに合うかどうかわかりません。これは問題の簡単な解決策ですが、少なくともHTMLタグの操作にRegExを使用しません。生のテキストに対してパターンマッチングを実行し、DOMを使用してコンテンツを操作します。


最初のアプローチ

このアプローチは、一致ごとに_<span>_タグを1つだけ作成し、あまり一般的でないブラウザーAPIを利用します。
(デモの下でこのアプローチの主な問題を参照してください。不明な場合は、2番目のアプローチを使用してください)

Range クラスはテキストフラグメントを表します。 surroundContents 関数があり、要素で範囲をラップできます。ただし、注意点があります。

このメソッドは、newNode.appendChild(range.extractContents()); range.insertNode(newNode)とほぼ同等です。囲んだ後、範囲の境界点にはnewNodeが含まれます。

ただし、Rangeが非Textノードを境界ポイントの1つだけで分割すると、例外がスローされます。つまり、上記の代替とは異なり、部分的に選択されたノードがある場合、それらは複製されず、代わりに操作が失敗します。

まあ、回避策はMDNで提供されているので、問題ありません。

だからここにアルゴリズムがあります:

  • Textノードのリストを作成し、それらの開始インデックスをテキストに保持します
  • これらのノードの値を連結してtextを取得します
  • テキスト上で一致を検索し、一致ごとに:

    • ノードの開始インデックスを一致位置と比較して、一致の開始ノードと終了ノードを見つけます
    • 試合でRangeを作成
    • 上記のトリックを使用して、ブラウザにダーティな作業を行わせます
    • 最後のアクションがDOMを変更してからノードリストを再構築します

これがデモ付きの私の実装です:

_function highlight(element, regex) {
    var document = element.ownerDocument;
    
    var getNodes = function() {
        var nodes = [],
            offset = 0,
            node,
            nodeIterator = document.createNodeIterator(element, NodeFilter.SHOW_TEXT, null, false);
            
        while (node = nodeIterator.nextNode()) {
            nodes.Push({
                textNode: node,
                start: offset,
                length: node.nodeValue.length
            });
            offset += node.nodeValue.length
        }
        return nodes;
    }
    
    var nodes = getNodes(nodes);
    if (!nodes.length)
        return;
    
    var text = "";
    for (var i = 0; i < nodes.length; ++i)
        text += nodes[i].textNode.nodeValue;

    var match;
    while (match = regex.exec(text)) {
        // Prevent empty matches causing infinite loops        
        if (!match[0].length)
        {
            regex.lastIndex++;
            continue;
        }
        
        // Find the start and end text node
        var startNode = null, endNode = null;
        for (i = 0; i < nodes.length; ++i) {
            var node = nodes[i];
            
            if (node.start + node.length <= match.index)
                continue;
            
            if (!startNode)
                startNode = node;
            
            if (node.start + node.length >= match.index + match[0].length)
            {
                endNode = node;
                break;
            }
        }
        
        var range = document.createRange();
        range.setStart(startNode.textNode, match.index - startNode.start);
        range.setEnd(endNode.textNode, match.index + match[0].length - endNode.start);
        
        var spanNode = document.createElement("span");
        spanNode.className = "highlight";

        spanNode.appendChild(range.extractContents());
        range.insertNode(spanNode);
        
        nodes = getNodes();
    }
}

// Test code
var testDiv = document.getElementById("test-cases");
var originalHtml = testDiv.innerHTML;
function test() {
    testDiv.innerHTML = originalHtml;
    try {
        var regex = new RegExp(document.getElementById("regex").value, "g");
        highlight(testDiv, regex);
    }
    catch(e) {
        testDiv.innerText = e;
    }
}
document.getElementById("runBtn").onclick = test;
test();_
_.highlight {
  background-color: yellow;
  border: 1px solid orange;
  border-radius: 5px;
}

.section {
  border: 1px solid gray;
  padding: 10px;
  margin: 10px;
}_
_<form class="section">
  RegEx: <input id="regex" type="text" value="[A-Z].*?\." /> <button id="runBtn">Highlight</button>
</form>

<div id="test-cases" class="section">
  <div>foo bar baz</div>
  <p>
    <b>HTML</b> is a language used to make <b>websites.</b>
        It was developed by <i>CERN</i> employees in the early 90s.
  <p>
  <p>
    This program is <a href="beta.html">not stable yet. Do not use this in production yet.</a>
  </p>
  <div>foo bar baz</div>
</div>_

OK、それはlazyアプローチでしたが、残念ながら一部のケースでは機能しません。インライン要素全体でのみを強調表示するとうまく機能しますが、 extractContentsの次のプロパティのため、途中にブロック要素があると機能しなくなります 関数:

部分的に選択されたノードは、ドキュメントフラグメントを有効にするために必要な親タグを含めるために複製されます。

それは良くないね。ブロックレベルのノードを複製するだけです。どのように機能するかを確認したい場合は、_baz\s+HTML_正規表現を使用して前のデモを試してください。


第二のアプローチ

このアプローチは、一致するノードを反復処理し、途中で_<span>_タグを作成します。

一致する各ノードを独自の_<span>_でラップするだけなので、全体的なアルゴリズムは単純です。しかし、これは、部分的に一致するテキストノードを処理する必要があることを意味します。

テキストノードが部分的に一致する場合、それは splitText 関数で分割されます:

分割後、現在のノードには指定されたオフセットポイントまでのすべてのコンテンツが含まれ、新しく作成された同じタイプのノードには残りのテキストが含まれます。新しく作成されたノードが呼び出し元に返されます。

_function highlight(element, regex) {
    var document = element.ownerDocument;
    
    var nodes = [],
        text = "",
        node,
        nodeIterator = document.createNodeIterator(element, NodeFilter.SHOW_TEXT, null, false);
        
    while (node = nodeIterator.nextNode()) {
        nodes.Push({
            textNode: node,
            start: text.length
        });
        text += node.nodeValue
    }
    
    if (!nodes.length)
        return;

    var match;
    while (match = regex.exec(text)) {
        var matchLength = match[0].length;
        
        // Prevent empty matches causing infinite loops        
        if (!matchLength)
        {
            regex.lastIndex++;
            continue;
        }
        
        for (var i = 0; i < nodes.length; ++i) {
            node = nodes[i];
            var nodeLength = node.textNode.nodeValue.length;
            
            // Skip nodes before the match
            if (node.start + nodeLength <= match.index)
                continue;
        
            // Break after the match
            if (node.start >= match.index + matchLength)
                break;
            
            // Split the start node if required
            if (node.start < match.index) {
                nodes.splice(i + 1, 0, {
                    textNode: node.textNode.splitText(match.index - node.start),
                    start: match.index
                });
                continue;
            }
            
            // Split the end node if required
            if (node.start + nodeLength > match.index + matchLength) {
                nodes.splice(i + 1, 0, {
                    textNode: node.textNode.splitText(match.index + matchLength - node.start),
                    start: match.index + matchLength
                });
            }
            
            // Highlight the current node
            var spanNode = document.createElement("span");
            spanNode.className = "highlight";
            
            node.textNode.parentNode.replaceChild(spanNode, node.textNode);
            spanNode.appendChild(node.textNode);
        }
    }
}

// Test code
var testDiv = document.getElementById("test-cases");
var originalHtml = testDiv.innerHTML;
function test() {
    testDiv.innerHTML = originalHtml;
    try {
        var regex = new RegExp(document.getElementById("regex").value, "g");
        highlight(testDiv, regex);
    }
    catch(e) {
        testDiv.innerText = e;
    }
}
document.getElementById("runBtn").onclick = test;
test();_
_.highlight {
  background-color: yellow;
}

.section {
  border: 1px solid gray;
  padding: 10px;
  margin: 10px;
}_
_<form class="section">
  RegEx: <input id="regex" type="text" value="[A-Z].*?\." /> <button id="runBtn">Highlight</button>
</form>

<div id="test-cases" class="section">
  <div>foo bar baz</div>
  <p>
    <b>HTML</b> is a language used to make <b>websites.</b>
        It was developed by <i>CERN</i> employees in the early 90s.
  <p>
  <p>
    This program is <a href="beta.html">not stable yet. Do not use this in production yet.</a>
  </p>
  <div>foo bar baz</div>
</div>_

これは私が望むほとんどの場合に十分なはずです。 _<span>_タグの数を最小限に抑える必要がある場合は、この関数を拡張することで実現できますが、とりあえず単純にしたいと考えています。

32

誰もがすでに言ったように、これは実際にはあなたのやり方ではないはずなので、これはより学術的な質問です。そうは言っても、面白そうだったので、ここに1つのアプローチを示します。

編集:私は今それの要点を得たと思います。

function myReplace(str) {
  myRegexp = /((^<[^>*]>)+|([^<>\.]*|(<[^\/>]*>[^<>\.]+<\/[^>]*>)+)*[^<>\.]*\.\s*|<[^>]*>|[^\.<>]+\.*\s*)/g; 
  arr = str.match(myRegexp);
  var out = "";
  for (i in arr) {
var node = arr[i];
if (node.indexOf("<")===0) out += node;
else out += "<span>"+node+"</span>"; // Here is where you would run whichever 
                                     // regex you want to match by
  }
  document.write(out.replace(/</g, "&lt;").replace(/>/g, "&gt;")+"<br>");
  console.log(out);
}

myReplace('<p>This program is <a href="beta.html">not stable yet. Do not use this in production yet.</a></p>');
myReplace('<p>This is a <b>sentence. </b></p>');
myReplace('<p>This is a <b>another</b> and <i>more complex</i> even <b>super complex</b> sentence.</p>');
myReplace('<p>This is a <b>a sentence</b>. Followed <i>by</i> another one.</p>');
myReplace('<p>This is a <b>an even</b> more <i>complex sentence. </i></p>');

/* Will output:
<p><span>This program is </span><a href="beta.html"><span>not stable yet. </span><span>Do not use this in production yet.</span></a></p>
<p><span>This is a </span><b><span>sentence. </span></b></p>
<p><span>This is a <b>another</b> and <i>more complex</i> even <b>super complex</b> sentence.</span></p>
<p><span>This is a <b>a sentence</b>. </span><span>Followed <i>by</i> another one.</span></p>
<p><span>This is a </span><b><span>an even</span></b><span> more </span><i><span>complex sentence. </span></i></p>
*/
5
Jan
function parseText( element ){
  var stack = [ element ];
  var group = false;
  var re = /(?!\s|$).*?(\.|$)/;
  while ( stack.length > 0 ){
    var node = stack.shift();
    if ( node.nodeType === Node.TEXT_NODE )
    {
      if ( node.textContent.trim() != "" )
      {
        var match;
        while( node && (match = re.exec( node.textContent )) )
        {
          var start  = group ? 0 : match.index;
          var length = match[0].length + match.index - start;
          if ( start > 0 )
          {
            node = node.splitText( start );
          }
          var wrapper = document.createElement( 'span' );
          var next    = null;
          if ( match[1].length > 0 ){
            if ( node.textContent.length > length )
              next = node.splitText( length );
            group = false;
            wrapper.className = "sentence sentence-end";
          }
          else
          {
            wrapper.className = "sentence";
            group = true;
          }
          var parent  = node.parentNode;
          var sibling = node.nextSibling;
          wrapper.appendChild( node );
          if ( sibling )
            parent.insertBefore( wrapper, sibling );
          else
            parent.appendChild( wrapper );
          node = next;
        }
      }
    }
    else if ( node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.DOCUMENT_NODE )
    {
      stack.unshift.apply( stack, node.childNodes );
    }
  }
}

parseText( document.body );
.sentence {
  text-decoration: underline wavy red;
}

.sentence-end {
  border-right: 1px solid red;
}
<p>This is a sentence. This is another sentence.</p>
<p>This sentence has <strong>emphasis</strong> inside it.</p>
<p><span>This sentence spans</span><span> two elements.</span></p>
5
MT0

このようなタスクには「フラットDOM」表現を使用します。

フラットDOMでは、この段落

<p>abc <a href="beta.html">def. ghij.</p>

2つのベクトルで表されます。

chars: "abc def. ghij.",
props:  ....aaaaaaaaaa, 

charsで通常の正規表現を使用して、propsベクトルのスパン領域をマークします。

chars: "abc def. ghij."
props:  ssssaaaaaaaaaa  
            ssss sssss

ここではスケマティック表現を使用していますが、実際の構造は配列の配列です。

props: [
  [s],
  [s],
  [s],
  [s],
  [a,s],
  [a,s],
  ...
]

変換ツリー-DOM <->フラットDOMは、単純な状態オートマトンを使用できます。

最後に、フラットDOMを次のようなツリーDOMに変換します。

<p><s>abc </s><a href="beta.html"><s>def.</s> <s>ghij.</s></p>

念のため:HTML WYSIWYGエディターでこのアプローチを使用しています。

4
c-smile