uhyohyo.net

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

八章第三回 Rangeの活用とSelection

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

Selectionとは

前回でRangeの機能はだいたい解説しました。しかし、正直なところ、これをいつ使う機会があるのか分からないという人もいるかもしれません。しかし、Selectionというものを使うときにはRangeが大きく関わってきます。

それでは、このSelectionとは何なのでしょうか。

これは、ユーザーが選択した範囲を表すものです。皆さんもブラウザ上でテキストを選択するという操作をしたことがあると思います。これは範囲なので、ここでRangeが登場するわけです。

Selectionの取得

Selectionは、Rangeとは違うオブジェクトです。Selectionを取得するには、window.getSelectionという関数を使います。引数はありません。

var selection = window.getSelection();

これでSelectionオブジェクトを取得できました。しかし、なぜ直接選択範囲のRangeを取得できないか疑問に思うかもしれません。これにはいくつか理由がありますが、「選択していない場合」や「2つ以上選択している場合」がありえるというのが理由の1つです。

それでは、このSelectionオブジェクトからRangeを取得するには、メソッドgetRangeAtを使います。これは数値の引数を1つ持ち、何番目のRangeを取得するかを指定します。

基本は、1つしかRangeがないので0ですが、もし2つ以上の範囲を同時に選択している場合に1以上を使用することがあるかもしれません。ちなみに、今何個Rangeがあるかは、rangeCountというプロパティでわかります。Rangeを処理するときは、これが0かどうかで処理を分ける必要がでてきます。0の場合、そもそも選択していないのでRangeを取得できません。

Selectionの機能

よく使う機能は、SelectionからRangeを除去する機能です。選択部分をSelectionから除去すると、実際に選択が解除されます。

Selectionのメソッドで、removeRangeというメソッドがあります。これは、引数で指定したRangeをSelectionから除去するというものです。このように除去すべきオブジェクトを直接指定するものには、かつてremoveChildがありましたね。

このメソッドの場合、getRangeAtで取得したRangeを引数に指定して削除することになるでしょう。


var sel = window.getSelection();
if(sel.rangeCount > 0){
  //Rangeが最低一つある場合
  sel.removeRange(sel.getRangeAt(0));
}

ただ、この方法はすこし面倒ですね。場合によってはもっと楽な方法があります。それはremoveAllRangesです。これは、引数無しで、全てのRangeを除去するというものです。


sel.removeAllRanges();

Selectionの実践

さて、Selectionを使えば、Rangeの機能を活かすことができそうです。ちょっとやってみましょう。

しかし、今までのようにbody要素の最後にscript要素を書くパターンだと、ページを読み込んだ瞬間にそれが実行されてそれっきりです。

Selectionはブラウザが選択した範囲をいじるものですから、読み込まれた瞬間に実行されても選択できません。そこで、選択したときを見計らって実行する必要があります。そういうときに使うのが、そう、イベントです。忘れた人は、三章を見るとよいでしょう。

今回はうってつけなイベントがあります。それはselectionchangeです。その名の通り、選択の状態が変わったときに発生するイベントです。これはdocumentで発生するイベントなので、documentにイベントハンドラを登録しましょう。


document.addEventListener('selectionchange',function(ev){


},false);

というようにします。さて、これを基本にやっていくわけですが、まずは簡単に、「選択しても消える」というのをやってみましょう。これは簡単で、removeAllRangesを実行するだけです。


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

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

    <script type="text/javascript">
      document.addEventListener('selectionchange',function(ev){
        var selection = getSelection();
        selection.removeAllRanges();
      },false);
    </script>
  </body>
</html>

これの挙動は、そもそもマウスで選択しようとしても選択できないという挙動になります。また、Ctrl+Aなどでページのテキストを選択しようとしてもできません。この理由は、マウスでの選択を開始した瞬間にそれ用のRangeが生成されてSelectionに追加されるので、選択の状態が変わったと見なされselectionchangeイベントが発生したと見なされるからです。

選択が終わった瞬間にイベントが起動して選択範囲を消し去るということをしたい気分になりますが、そのようなイベントは存在しませんので、やりたい場合には工夫が必要になります。

ところで、余談ですが、このサンプルを見て「サイトにコピーして欲しくない文章があるから、これを使って対策しよう」とは思いませんよね。ソースを見れば簡単にコピーされるのですから。そんなことを思っているうちはまだまだ初心者なのです。

では、次に「選択した部分がページから消える」というのを作ってみましょう。前回、RangeのdeleteContentsを解説したので、選択部分のRangeを取得してこれを使えば消せそうです。

ここで先ほど述べた「選択が終わった瞬間」を検出するための工夫が必要になります。しかし、ここではそれは本題ではないので、雑ですが簡単な工夫で乗り切ることにします。具体的にはselectionchangeイベントの代わりにmouseupイベントを使います。これは名前の通り、マウスのボタンが上がった瞬間に発生するイベントです。逆にマウスのボタンが押された瞬間のイベントはmousedownです。


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

今回はRangeを取得して扱うので、マウスを押したけど選択していない場合を考慮し、rangeCountをチェックしてRangeが取得できる場合のみ処理をしています。

サンプル:選択部分のスタイル操作

さらに、もう少し工夫がいる例として、「選択した部分の背景色が変わる」というのをやってみましょう。

色を変えるには、スタイルシートを使うことになります。今回の場合、background-colorプロパティを変えればいいですね。

さて、これは今までのより少し複雑です。ちょっと考えてみましょう。

まとめて囲む

まず思いつくのが、background-colorを指定した要素を用意し、選択部分全部そこにいれてしまうというものです。例えば、span要素で囲むとすると、


abcde<em>fghijklmn</em>opqrstuvwxyz
   |---------------------|

というように選択したとすると、


abc<span>de<em>fghijklmn</em>opq</span>rstuvwxyz

となるということです。

実は、前回紹介しませんでしたが、Rangeにはまさにこれ用のメソッドがあります。surroundContentsです。引数に新しい親となる要素を渡すと、Rangeで囲まれた部分が全部与えられた要素の子となり、Rangeで囲まれた部分がその要素で置換されます。


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";

    range.surroundContents(newspan);
  }
},false);

テキストを適当に選択してみると、いい感じに背景が変わってくれます。

しかし、まだまだ不完全です。今回のサンプルには3つのp要素がありましたが、2つ以上のp要素にまたがって選択すると、なんとエラーが起きます。このエラーはsurrondContentsが起こしたもので、原因はつまりこういうことです。


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

のようにRangeで選択されているとき、これを囲もうとすると


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

となります。これは明らかにHTMLの文法に違反しており、木構造的に見ても囲むことは不可能です。このように、囲むことができない場合にはsurroundContentsはエラーを出してくるのです。これは仕方ないことですが、surrondContentsを使う際は注意しましょう。

ちなみに、動作しないのは仕方ないにしても、いちいちエラーを出すのが格好悪いという場合には、「try-catch構文」(機会があれば紹介するかもしれません)を使えばなんとかなります。

さて、このような場合でもちゃんと色を変化させたい場合は、surrondContentsを使わずにやることになります。囲むという作業を分解すると、「抜き出す」「親で囲む」「親を戻す」という3つの作業に分解することができます。これをそれぞれやればいいのです。

まず抜き出すのはextractContentsを使います。戻り値はDocumentFragmentでした。このextractContentsは、どんな範囲であろうとうまく抜き出してくれます。

次に親で囲むのは、上と同じようにspan要素を作った後、戻り値のDocumentFragmentをappendChildするだけです。DocumentFragmentは「親がいない兄弟」のような感じなので、抜き出した部分がちゃんと全部span要素に入ってくれます。

最後に、前回紹介したinsertNodeで抜き出した場所にspan要素を戻します。ここでは、隙間に新しい要素を埋めるだけなので、やはり問題は起こりません。

つまり、こうです。


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);

これでも先ほどのsurroundContentsと同じようなことができています。しかし、実はこれにも問題が残っています。さっきのようにp要素をまたいで選択すると、エラーは起きないものの、p要素が増殖してしまいます。もっとも、それ以前にもう1つ問題があるのですが。

p要素が増殖する原因は、extractContentsの動作にあります。といっても、これも仕方ないことです。


<p>............</p>......…
        |----------------…

というような範囲の状態でextractContentsをすると、開始点より左の部分も確かにp要素の中、右もp要素の中、ということを両立するために、


<p>.....</p><p>.......</p>......…
            |-------------------…

というようにp要素が分割されて抜き出されます。この動作は前回説明した通りですね。

右の部分をspanで囲んで戻すと、


<p>.....</p><span><p>.......</p>......…</span>

となり、1つだったp要素が2つに増えています。

ちなみに、「それ以前の問題」とは、そもそもspan要素の中にp要素が入っていることです。これはいけません。

さて、原因が分かったところで、これの解決は次回ということにします。