

ある要素の innerText/textContent のうち、特定の正規表現（例:
`/#\d+/`）に該当する文字列をタグで囲み、イベントハンドラを設定したいと考えています。例えば、`<p>その件は #123
を参照してください。</p>` というHTMLの中で `#123`
にマウスカーソルを乗せると、対応するIssueの詳細がポップアップされる、といったものです。

一つ思いついた方法としては、innerHTML に対して置換を行うものです。

[code]

    target.innerHTML = target.innerHTML
                       .replace(/#(\d+)/g, (s, id) => `<a href=".../${id}">${s}</a>`);
    
[/code]

単純なリンクに置き換えるだけならこれでもいいのですが、設定する属性やイベントハンドラが多くなってくるとタグに記載するのは面倒ですし、読みにくくなってしまいます。かといってこの時点では文字列なので、Elementオブジェクトにはアクセスできません。

UserScript として作る都合上、できれば class 属性等を使うのも避けたいです（対象ページでの使用箇所と競合する可能性があるので）。

選択範囲に対して同様のことをした時には
[`Range.prototype.surroundContents()`](https://developer.mozilla.org/en-
US/docs/Web/API/range/surroundContents)
が使えたので、今回もマッチ範囲をRangeで取得する、なんてことができれば簡単そうなのですが…。


CC BY-SA 3.0
2017-03-28T07:16:48.870
33603
2017-12-08T04:12:09.237
2017-03-29T22:53:05.230
20204
20204
question
javascript html
正規表現にマッチしたテキストをタグで囲み、イベントハンドラを設定したい


DOMはDOMオブジェクトで表現されていないものに対する操作がとても苦手です。というかほぼできません。逆に言えばDOMに落とし込めれば操作可能になります。  
HTMLにおける文字列はDOMにおいて`nodeType ===
TEXT_NODE`のNodeオブジェクト、いわゆるtextNodeとして構成されますが、このオブジェクトは任意長の部分文字列に分割することが可能です（子要素にtextNodeがいくつあってもよい/最初のtextNodeが.textContentに一致するわけではない）。今回のアプローチではまず対象となる文字列を部分文字列のtextNodeに分割します。  
しかしながらtextNodeはEventTargetを継承していません（`Node instanceof EventTarget ===
false`)ので、ふつうのDOM要素（HTMLElement）とは異なりイベントを登録できません。これを解決するために、質問内容でいうところの「HTML要素に置き換える」操作が解決方法になるわけです。  
以下、サンプルコードでは部分文字列の生成と置換を同時に行っています。部分文字列の検索はtextNodeを[NodeIterator](https://developer.mozilla.org/en-
US/docs/Web/API/NodeIterator)による列挙で抽出し、それぞれのtextNodeの内容を文字列検索・分割・置換しています。関数`__replaceTextNodes`は置換した要素への参照配列をかえしますので、その後のアプリケーションでイベントハンドラの登録等に利用できます。全体としてはRange.prototype.surroundContents()のpolyfillのような印象になりました。

[code]

    const __getTextNodesByContent = (target, pattern) => {
        // get iterator
        const textNodeIterator = document.createNodeIterator(
            // search for
            target,
            // enumerate for
            NodeFilter.SHOW_TEXT,
            // filter
            {
                acceptNode: node => pattern.test(node.textContent) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT,
            }
        );
    
        // into array
        const ret = [];
        let current;
        while (current = textNodeIterator.nextNode()) {
            ret.push(current);
        }
    
        return ret;
    };
    
    const __replaceTextNodes = (target, pattern, replace) => {
        // get the list of textNodes that contains /pattern/
        const matchedTextNodes = __getTextNodesByContent(target, pattern);
    
        // for each textnode
        const ret = [];
        for (const node of matchedTextNodes) {
            // replace each part of textNode
            let currentNode = node;
            let matches;
    
            while (pattern.lastIndex = 0, matches = pattern.exec(currentNode.textContent)) {
                // separate [currentNode|<before><match><after>]
                // into [currentNode|<before>] [nextNode|<match><after>]
                const nextNode = currentNode.splitText(matches.index);
    
                // slice <match> part; [currentNode|<before>] [nextNode|<after>]
                nextNode.textContent = nextNode.textContent.slice(matches[0].length);
    
                // insert surround <match> part as a new sibling;
                // [currentNode|<before>] [surroundNode|<match>] [nextNode|<after>]
                const surroundNode = replace(matches[0]);
                nextNode.parentElement.insertBefore(surroundNode, nextNode);
    
                // store reference
                ret.push(surroundNode);
    
                // next
                currentNode = nextNode;
            }
        }
    
        // return a list of surround elements
        return ret;
    };
    
    //
    // for example
    //
    const $target = document.body;
    const targetPattern = /Lorem|massa|ridiculus|pellentesque/gi;
    
    const surroundElements = __replaceTextNodes($target, targetPattern, text => {
        // surround with <span>, colored with red, add title attribute
        const el = document.createElement('span');
        el.textContent = text;
        el.style.color = 'red';
        el.title = 'surround with <span>, text content is "' + text + '";';
        return el;
    });
    
    // event handler filterd by surround elements
    window.addEventListener('click', e => {
        if (surroundElements.includes(e.target)) {
            console.log('click', e.target);
        }
    });
    
    // or for each surround elements
    /*
    for (const se of surroundElements) {
        se.addEventListener(evt, fn);
    }
    */
[/code]

[code]

    <h1>Lorem Lorem ipsum dolor sit amet consectetuer adipiscing 
    elit</h1>
    
    <p>Lorem ipsum dolor sit amet, consectetuer adipiscing 
    elit. Aenean commodo ligula eget dolor. Aenean massa 
    <strong>strong</strong>. Cum sociis natoque penatibus 
    et magnis dis parturient montes, nascetur ridiculus 
    mus. Donec quam felis, ultricies nec, pellentesque 
    eu, pretium quis, sem. Nulla consequat massa quis 
    enim. Donec pede justo, fringilla vel, aliquet nec, 
    vulputate eget, arcu. In enim justo, rhoncus ut, 
    imperdiet a, venenatis vitae, justo.</p>
    
    <h2>Lorem ipsum dolor sit amet consectetuer adipiscing 
    elit</h2>
    
    <h3>Aenean commodo ligula eget dolor aenean massa</h3>
    
    <p>Lorem ipsum dolor sit amet, consectetuer adipiscing 
    elit. Aenean commodo ligula eget dolor. Aenean massa. 
    Cum sociis natoque penatibus et magnis dis parturient 
    montes, nascetur ridiculus mus. Donec quam felis, 
    ultricies nec, pellentesque eu, pretium quis, sem.</p>
    
    <ul>
      <li>Lorem ipsum dolor sit amet consectetuer.</li>
      <li>Aenean commodo ligula eget dolor.</li>
      <li>Aenean massa cum sociis natoque penatibus.</li>
    </ul>
[/code]

期待した動作ではない、バグがある、その他不明な点についてはコメントをお願いします。

* * *

参考:

_innerHTML_ プロパティの変更によるDOMの操作は _極めて_
高コストかつリスクが高いことを認識しておいてください。個人的な意見としては何が何でも使わないと実現できない場合のみ使用するものとし、リフレクション目的以外では使用禁止にしたいぐらいです。次の例では何の変更もしていないようで、body以下すべての要素が置換されています。

[code]

    const $target = document.querySelector('#target');
    $target.addEventListener('click', e => console.log(e));
    
    // will be true
    console.log($target === document.querySelector('#target'));
    
    // reconstruct; SCRAP AND REBUILD ALL DOM ELEMENTS
    document.body.innerHTML = document.body.innerHTML;
    
    // will be false
    console.log($target === document.querySelector('#target'));
    
    // now no event listener attached to <div#target>
[/code]

[code]

    <div id="target">target element</div>
[/code]


CC BY-SA 3.0
2017-03-28T12:28:22.137
33610
2017-03-30T03:58:47.560
2017-03-30T03:58:47.560
33603
answer


XPath式 `descendant::text()[contains(.,"#123")]`
を使用する事で文字列「`"#123"`」を含むテキストノードを得る事が可能です。  
ただし、XPath 1.0 では `"` のエスケープシーケンスが利用できない為、`concat()`
を利用して対応させます(詳細は後述の参考リンクを参照)。

  * [指定文字列を含むテキストノードを置換する - JSFiddle](https://jsfiddle.net/4047z5vp/2/)

**(2017/03/30 08:00追記)**  
すみません。質問内容を誤解していました。  
正規表現でマッチさせるなら「XPath式 `:matches()` を使う」もしくは「全てのテキストノードを参照後に
`/#\d+/.test(textNode.data)` を `while` 文で回す」方法が考えられます。

  * [7.6.2 fn:matches - XQuery 1.0 and XPath 2.0 Functions and Operators (Second Edition)](https://www.w3.org/TR/xpath-functions/#func-matches)

[code]

    'use strict';
    var toXPathStringLiteral = (function () {
      function replacefn (match, p1) {
    
        if (p1) {
          return ',\u0027' + p1 + '\u0027';
        }
    
        return ',"' + match + '"';
      }
    
      return function toXPathStringLiteral (string) {
        string = String(string);
    
        if (/^"+$/g.test(string)) {
          return '\u0027' + string + '\u0027';
        }
    
        switch (string.indexOf('"')) {
          case -1:
            return '"' + string + '"';
          case 0:
            return 'concat(' + string.replace(/("+)|[^"]+/g, replacefn).slice(1) + ')';
          default:
            return 'concat(' + string.replace(/("+)|[^"]+/g, replacefn) + ')';
        }
      };
    }());
    
    function handleClick (event) {
      console.log(event.target.textContent);
    }
    
    function markupHighlight (targetString, contextNode) {
      var doc = contextNode.nodeType === contextNode ? contextNode : contextNode.ownerDocument,
          xpathResult = doc.evaluate('descendant::text()[contains(.,' + toXPathStringLiteral(targetString) + ')]', contextNode, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null),
          df = doc.createDocumentFragment(),
          mark = doc.createElement('mark');
    
      mark.appendChild(doc.createTextNode(targetString));
    
      for (var i = 0, xLen = xpathResult.snapshotLength, currentTextNode, stringList, textNode; i < xLen; ++i) {
        currentTextNode = xpathResult.snapshotItem(i);
        stringList = currentTextNode.data.split(targetString);
        textNode = df.appendChild(doc.createTextNode(stringList[0]));
    
        for (var j = 1, stringLen = stringList.length, textNode; j < stringLen; ++j) {
          mark = mark.cloneNode(true);
          df.appendChild(mark);
          mark.addEventListener('click', handleClick, false);
          textNode = textNode.cloneNode(false);
          textNode.data = stringList[j];
          df.appendChild(textNode);
        }
    
        currentTextNode.parentNode.replaceChild(df, currentTextNode);
      }
    }
    
    markupHighlight('foo', document.body);
[/code]

[code]

    mark {
      color: black;
      background-color: #ddf;
      border: solid 1px #55f;
    }
[/code]

[code]

    <ul id="foo">
      <li>foo1</li>
      <li>foo2</li>
      <li>foo3</li>
      <li>
        <ul>
          <li>foo4-1</li>
          <li>foo4-2 foo4-2</li>
          <li>
            <ul>
              <li>foo4-3-1</li>
              <li>foo4-3-2 foo4-3-2 foo4-3-2</li>
            </ul>
          </li>
        </ul>
      </li>
    </ul>
[/code]

### 参考リンク

  * [JavaScript - XPath 式の文字列リテラルでダブルクォートをエスケープするには？(31949)｜teratail](https://teratail.com/questions/31949)
  * [JavaScript - XPath 式でテキストノード値を指定してフィルタするには？(31198)｜teratail](https://teratail.com/questions/31198)

Re: user20204 さん


CC BY-SA 3.0
2017-03-29T17:55:55.080
33643
2017-03-29T23:00:39.077
2017-03-29T23:00:39.077
20262
20262
33603
answer