uhyohyo.net

JavaScript初級者から中級者になろう

八章第四回 サンプルの改良

このページの最終更新日:

考え方

今回は、前回の最後に作ったサンプルを完成させる回です。前回の最後は結局次のような状態でした。


<!doctype html>
<html>
  <head>
    <title>test</title>
  </head>
  <body>

    <p>あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん</p>
    <p>あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん</p>
    <p>あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん</p>

    <script type="text/javascript">
      document.addEventListener('mouseup',function(ev){
        var selection = getSelection();
        if(selection.rangeCount > 0){
          var range = selection.getRangeAt(0);
          //新しいspan要素を作る
          var newspan = document.createElement('span');
          //background-colorを設定
          newspan.style.backgroundColor="#ffff00";

          var df = range.extractContents();
          newspan.appendChild(df);
          range.insertNode(newspan);
        }
      },false);
    </script>
  </body>
</html>

という物を作ったのでした。しかし、問題もあるということは前回解説しました。

そこで、今回それをどう改良するかということをまず解説します。

なお、今回Rangeの新しい機能は登場しません。それ以外の新要素は2つ登場します。ページの最後にまとまっていますので、今回のサンプルに興味がない方はそこだけ参照してください。

さて、今回どうやって背景色を変えるかというと、Rangeに含まれる要素ひとつひとつにスタイルを指定して回るという方針でいきます。

つまり、


<p>.........</p>
<p>.....</p>
<p>........</p>

というように選択していた場合、適宜spanを補いつつ、


<p>......<span style="background-color:yellow">...</span></p>
<p style="background-color:yellow">.....</p>
<p><span style="background-color:yellow">...</span>.....</p>

というようにします。このように、真ん中のp要素に関しては全て範囲内なので、そのp要素に直接スタイルを指定してしまいます。

Rangeの開始点や終了点がテキストの途中にある場合はテキストに直接スタイルを適用できませんので、span要素を補います。

今回は、一番上(body要素)から順に、どこを着色すべきか調べていくことにしましょう。方針としては、あるノードを調べたとき、そのノードが完全にRangeに含まれていればそのノードを着色する。そのノードにRangeの範囲の一部が入っていれば中を詳しく調べる。完全にRangeの範囲外ならそのノードはやめて次を調べる。このような方針をとりましょう。

この方針は再帰関数を使えば簡単に実現できます。上で「あるノードを調べる」という表現がありましたが、これに対応する関数を作ります。


document.addEventListener('mouseup',function(ev){
  // 現在の選択範囲を調べる
  var selection = getSelection();
  if(selection.rangeCount > 0){
    var range = selection.getRangeAt(0);
    checkNode(document.body, range)
  }
}
// メインの関数
// nodeは現在調べているノード、rangeは着色したい範囲のRange
function checkNode(node, range){
}

上で述べた処理の実現のためには、nodeがrangeの中に含まれているかどうか調べる必要があります。このために使えるのがcompareBoundaryPointsです。nodeの範囲とちょうど一致する範囲を表すRangeを作ることでnodeとrangeの位置関係を知ることができます。


// メインの関数
// nodeは現在調べているノード、rangeは着色したい範囲のRange
function checkNode(node, range){
  // 新しいRangeを作る
  var nodeRange = new Range();
  // nodeRangeの範囲をnodeを囲むように設定
  nodeRange.selectNode(node);

  if(range.compareBoundaryPoints(Range.START_TO_START, nodeRange) <= 0 &&
     range.compareBoundaryPoints(Range.END_TO_END, nodeRange) >= 0){
    // nodeRangeはrangeに囲まれている
    // → このノード全体を着色して終わり
    node.style.backgroundColor = "#ffff00";
  }else if(range.compareBoundaryPoints(Range.START_TO_END, nodeRange) <=0 ||
            range.compareBoundaryPoints(Range.END_TO_START, nodeRange) >=0){
    // nodeRangeとrangeは重なっていない
    // →このノードをこれ以上調べる必要はない
    return;
  }else{
    // このノードは一部rangeに含まれている
    for(var i=0; i<node.childNodes.length; i++){
      // 子ノードをひとつずつ調べる
      checkNode(node.childNodes[i], range);
    }
  }
}

これはなかなか簡潔でわかりやすいプログラムですが、これではまだひとつ考慮できていない場合があります。それは、テキストノードを着色したい場合です。上の関数ではnode.style.backgroundColor = "#ffff00";としていましたが、これは明らかにnodeがHTML要素の場合を想定しており、nodeがテキストノードの場合はこれだと無理です。

テキストノードを着色したい場合は、span要素でそのテキストノードで囲むことで対処するのがよいでしょう。つまり、関数を次のように改良します。


// メインの関数
// nodeは現在調べているノード、rangeは着色したい範囲のRange
function checkNode(node, range){
  // 新しいRangeを作る
  var nodeRange = new Range();
  // nodeRangeの範囲をnodeを囲むように設定
  nodeRange.selectNode(node);

  if(range.compareBoundaryPoints(Range.START_TO_START, nodeRange) <= 0 &&
     range.compareBoundaryPoints(Range.END_TO_END, nodeRange) >= 0){
    // nodeRangeはrangeに囲まれている
    // → このノード全体を着色して終わり
    if(node.nodeType == Node.TEXT_NODE){
      // テキストノードの場合はspanで囲む
      var span = document.createElement('span');
      // まずspanをテキストノードの直前に設置
      node.parentNode.insertBefore(span, node);
      // テキストノードをspanの中に移す
      span.appendChild(node);
      span.style.backgroundColor = "#ffff00";
    }else{
      // テキストノードでない場合は普通に着色
      node.style.backgroundColor = "#ffff00";
    }
  }else if(range.compareBoundaryPoints(Range.START_TO_END, nodeRange) <=0 ||
            range.compareBoundaryPoints(Range.END_TO_START, nodeRange) >=0){
    // nodeRangeとrangeは重なっていない
    // →このノードをこれ以上調べる必要はない
    return;
  }else{
    // このノードは一部rangeに含まれている
    for(var i=0; i<node.childNodes.length; i++){
      // 子ノードをひとつずつ調べる
      checkNode(node.childNodes[i], range);
    }
  }
}

これにはまだ問題があることに気づいたでしょうか。それは、テキストノードの一部のみ着色したい場合です。対処法は、テキストノードを分割することで、着色されるべきノードと着色されないべきノードを分けることです。

これは上記の関数に組み込むより事前に処理をしてしまったほうが楽なので、別の関数にしましょう。


// 前処理
function sanitize(range){
  // 開始点がテキストノードの中だったら
  if(range.startContainer.nodeType == Node.TEXT_NODE){
    // テキストノードをRangeの開始点の位置で2つに分ける
    var latter = range.startContainer.splitText(range.startOffset);
    // Rangeの開始点をテキストノードの外側にする
    range.setStartBefore(latter);
  }
  // 終了点にも同様の処理
  if(range.endContainer.nodeType == Node.TEXT_NODE){
    var latter = range.endContainer.splitText(range.endOffset);
    range.setEndBefore(latter);
  }
}

以上を組み合わせれば完成です。試してみましょう。


<!doctype html>
<html>
  <head>
    <title>test</title>
  </head>
  <body>

    <p>あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん</p>
    <p>あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん</p>
    <p>あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん</p>

    <script type="text/javascript">
      document.addEventListener('mouseup',function(ev){
        var selection = getSelection();
        if(selection.rangeCount > 0){
          var range = selection.getRangeAt(0);

          sanitize(range);
          checkNode(document.body, range);
          // 変化が分かるように選択範囲は消す
          selection.removeAllRanges();
        }
      },false);

      // 前処理
      function sanitize(range){
        // 開始点がテキストノードの中だったら
        if(range.startContainer.nodeType == Node.TEXT_NODE){
          // テキストノードをRangeの開始点の位置で2つに分ける
          var latter = range.startContainer.splitText(range.startOffset);
          // Rangeの開始点をテキストノードの外側にする
          range.setStartBefore(latter);
        }
        // 終了点にも同様の処理
        if(range.endContainer.nodeType == Node.TEXT_NODE){
          var latter = range.endContainer.splitText(range.endOffset);
          range.setEndBefore(latter);
        }
      }

      // メインの関数
      // nodeは現在調べているノード、rangeは着色したい範囲のRange
      function checkNode(node, range){
        // 新しいRangeを作る
        var nodeRange = new Range();
        // nodeRangeの範囲をnodeを囲むように設定
        nodeRange.selectNode(node);

        if(range.compareBoundaryPoints(Range.START_TO_START, nodeRange) <= 0 &&
           range.compareBoundaryPoints(Range.END_TO_END, nodeRange) >= 0){
          // nodeRangeはrangeに囲まれている
          // → このノード全体を着色して終わり
          if(node.nodeType == Node.TEXT_NODE){
            // テキストノードの場合はspanで囲む
            var span = document.createElement('span');
            // まずspanをテキストノードの直前に設置
            node.parentNode.insertBefore(span, node);
            // テキストノードをspanの中に移す
            span.appendChild(node);
            span.style.backgroundColor = "#ffff00";
          }else{
            // テキストノードでない場合は普通に着色
            node.style.backgroundColor = "#ffff00";
          }
        }else if(range.compareBoundaryPoints(Range.START_TO_END, nodeRange) <=0 ||
                  range.compareBoundaryPoints(Range.END_TO_START, nodeRange) >=0){
          // nodeRangeとrangeは重なっていない
          // →このノードをこれ以上調べる必要はない
          return;
        }else{
          // このノードは一部rangeに含まれている
          for(var i=0; i<node.childNodes.length; i++){
            // 子ノードをひとつずつ調べる
            checkNode(node.childNodes[i], range);
          }
        }
      }
    </script>
  </body>
</html>

これで完成です。ところで、今回新しく登場したものが2つあります。最後にこれについて紹介して終わります。

nodeType

nodeTypeはノードが持つプロパティで、そのノードの種類を示す数値が入っています。上のサンプルではノードがテキストノードかどうか判定するために使われましたね。

お察しの通り、各数値には対応する定数があります。これらはNodeオブジェクトのもつ定数となっていて、TEXT_NODE(テキストノード)の他にもELEMENT_NODE(要素)などがあります。

splitText

もう1つ、sanitize関数の中でsplitTextというメソッドが使われています。これはText(テキストノード)が持つメソッドで、テキストノードをある位置で2つに分割するメソッドです。その位置は数値で示します。見て分かるとおり、この位置は一番最初を0,次を1,……とするもので、Rangeのオフセットと同じ考え方です。

splitTextにより新しいテキストノードが1つ作られます。すなわち、もともとのテキストノードから分割点より後ろの文字列が除去され、直後に追加された新しいテキストノードに移されます。splitTextの返り値は、後ろに作られた新しいテキストノードです。