【JavaScript】マウス、タッチ、ペンを包括的に扱う PointerEvent

近年の入力デバイスにはマウスやタッチのほかペンも存在します。JavaScript で入力を扱うために、マウスであれば MouseEvent、タッチであれば TouchEvent を使ってきました。しかしペンに特化したイベントがありません。

今やペンは当たり前の入力デバイスとなり、MouseEvent や TouchEvent だけでは対処しきれなくなりました。今回はそのような状況の中で誕生した新たな API である PointerEvent を紹介します。

MouseEvent と TouchEvent の問題点

スマートフォンがまだ存在しない時代、コンピューターの入力デバイスはマウスがほとんどを占めており、JavaScript の API としては MouseEvent が使えれば困ることはありませんでした。

スマートフォンが登場した当初は、指による操作も MouseEvent で何とか対処してきました。タップといったマウスクリックと同等のアクションなら問題ありませんが、多点タッチなどのタッチ入力独特な入力をハンドリングできませんでした。それを解決するべく登場したのが TouchEvent です。

ところが、入力をハンドリングするイベントが MouseEvent と TouchEvent に分かれている点が弊害をもたらす場合があります。

近年のウェブサイトやウェブアプリは PC でもスマートフォンでも期待通りに動作するよう作られます。クリックに相当するイベントしかハンドリングしないのであれば MouseEvent のみを使うことで弊害はありません。しかし、スマートフォンの場合はタッチに特化したきめ細やかな入力ハンドリングを行いたい場合もあるでしょう。この場合、MouseEvent だけでなく TouchEvent も扱うことになりますが、この両使いで弊害が発生します。

何が問題なのかというと、スマートフォンでタップすると、TouchEvent のイベントだけでなく、MouseEvent のイベントも発生してしまいます。それぞれのイベントリスナーに処理が定義されてていると、どちらの処理も実行することになってしまいます。それを回避するために、余計な配慮が必要になってきます。詳細は、こちらの記事をご覧ください。

ペン独自のイベント情報と PointerEvent の必要性

マウスのイベントなら、その座標などのイベント情報を MouseEvent から入手できます。タッチであれば、多点タッチなどの情報を TouchEvent から入手できます。ところが、ペンに関しては詳細な情報を取得する手段がありませんでした。

ペンに特化したイベント情報とは、たとえば、筆圧やペンの傾きなどです。PenEvent という新たなイベント仕様を作ることもできたでしょう。しかし、将来的に新たな入力デバイスが登場するたびに新たなイベント仕様を作っていたらキリがありません。また、前述のような問題点も発生し、ウェブ開発がより複雑になってしまいます。

そこで考え出されたのが PointerEvent です。PointerEvent は、マウス、タッチ、ペンそれぞれの物理的な入力デバイスをポインターという 1 つの概念で表現したものです。もともとは Microsoft 社が W3C に提案し、Internet Explorer に実装されたのが始まりでした。

ポインターの概念(出典: W3C Pointer Events Level 2

現在では、PointerEvent はすべてのメジャーなブラウザーでサポートされています。

サンプルコード

まずは、PointerEvent で規定されている pointerdown イベントを使ってイベント情報を見てみましょう。

<pre id="box"></pre>
const box_el = document.getElementById('box');
box_el.addEventListener('pointerdown', (event) => {
  const data = {
    pointerId: event.pointerId,
    width: event.width,
    height: event.height,
    pressure: event.pressure,
    tangentialPressure: event.tangentialPressure,
    pointerType: event.pointerType,
    tiltX: event.tiltX,
    tiltY: event.tiltY,
    twist: event.twist,
    isPrimary: event.isPrimary
  };
  box_el.textContent = JSON.stringify(data, null, '  ');
});

このサンプルコードは、pre 要素で pointerdown イベントが発生したときに、PointerEvent から得られるイベント情報のうち、PointerEvent だけに規定されたプロパティを画面に表示します。マウスボタンを押したときの結果は次の通りです。

{
  "pointerId": 1,
  "width": 1,
  "height": 1,
  "pressure": 0.5,
  "tangentialPressure": 0,
  "pointerType": "mouse",
  "tiltX": 0,
  "tiltY": 0,
  "twist": 0,
  "isPrimary": true
}

PointerEvent のイベントプロパティ

前述の PointerEvent のイベントプロパティについて、それぞれ解説します。

プロパティ説明
pointerIdマウス、タッチ、ペンのポインターを区別する ID。クリックやタップをするたびに値が増えていきます。
width指など接触している範囲の幅です。単位はピクセルです。接触範囲をサポートしていないデバイスでは 常に 1 になります。
height 指など接触している範囲の高さです。単位はピクセルです。接触範囲をサポートしていないデバイスでは 常に 1 になります。
pressure筆圧を 0.0 から 1.0 の範囲で表します。筆圧をサポートしていないデバイスでは 常に 0.5 になります。
tangentialPressureバレル圧を -1.0 から 1.0 の範囲で表します。 バレル圧をサポートしていないデバイスでは常に 0 になります。
pointerTypeポインターの種類を表します。マウスなら “mouse”、タッチなら “touch”、ペンなら “pen” がセットされます。
tiltXペンの X 軸方向の傾きを -90 から 90 ° で表します。ペンが垂直に立った状態なら 0、右に傾いていれば正の値、左に傾いていれば負の値になります。マウスやタッチなど傾きをサポートしていないデバイスでは常に 0 になります。
tiltY ペンの Y 軸方向の傾きを -90 から 90 ° で表します。ペンが垂直に立った状態なら 0、手前(下)に傾いていれば正の値、奥(上)に傾いていれば負の値になります。マウスやタッチなど傾きをサポートしていないデバイスでは常に 0 になります。
twistペンの回転角度を 0 から 359 ° で表します。時計回りに値が増えていきます。 マウスやタッチなど回転角度をサポートしていないデバイスでは常に 0 になります。
isPrimary該当のポインターがプライマリポインターなら true が、そうでなければ false がセットされます。

tiltXtiltY は図を見た方が分かりやすいでしょう。W3C の仕様書「Pointer Events Level 2」の図を引用します。

tiltX が正の値の場合
tiltY が負の値の場合

前述のサンプルコードでタッチした場合の結果を見てみましょう。タッチの範囲を表す widthheight に値がセットされていることが分かります。

{
  "pointerId": 2,
  "width": 73.71428680419922,
  "height": 49.71428680419922,
  "pressure": 0.5,
  "tangentialPressure": 0,
  "pointerType": "touch",
  "tiltX": 0,
  "tiltY": 0,
  "twist": 0,
  "isPrimary": true
}

次にペンの場合の結果を見てみましょう。筆圧を表す pressure、傾きを表す tiltX, tiltY に値がセットされていることが分かります。

{
  "pointerId": 6,
  "width": 1,
  "height": 1,
  "pressure": 0.365234375,
  "tangentialPressure": 0,
  "pointerType": "pen",
  "tiltX": 29,
  "tiltY": 13,
  "twist": 0,
  "isPrimary": true
}

筆者は Microsoft Surface Pro 8 と Surface Slim Pen 2 を使ってペンの挙動を試しましたが、残念ながら tangentialPressuretwist プロパティの値は取り出せませんでした。

pointerEvent のイベント

これまで pointerdown イベントを使って概要を説明しましたが、pointerEvent には様々なイベントが用意されています。

イベント名発生のタイミング
pointeroverポインターが要素の中に入ってきたとき。
pointerenterポインターが要素の中に入ってきたとき。ただしバブリングしない。
pointerdownマウスボタンが押下された、または、それに相当するアクションがタッチまたはペンで発生したとき。
pointermoveポインターの座標が変化したとき。
pointerupマウスボタンが離された、または、それに相当するアクションがタッチまたはペンで発生したとき。
pointercancelポインターが無効になったとき。
pointeroutポインターが要素の外に出たとき。
pointerleaveポインターが要素の外に出たとき。ただし、バブリングしない。
gotpointercaptureポインターが発生したとき
lostpointercaptureポインターが消失したとき

上記のイベントのうち、いくつかは分かりにくいものがあるため、以降で補足します。

pointerover と pointerenter イベントの違い

pointerover と pointerenter のどちらのイベントも、基本的にはポインターが要素の中に入っていたときに発出されます。マウスであれば、ポインターが要素の外から中に入ってくる、という状況は理解しやすいでしょう。タッチの場合は、要素の境界をまたがることなく、いきなり要素の中に入ってきます。ペンはホバーをサポートしていればマウスと同様ですし、サポートしていなければタッチと同じです。いずれにせよ、マウスを動かして要素に入ったり、要素の中をいきなり指でタップしても、pointerover と pointerenter イベントのどちらも該当の要素で発出されます。

しかし、pointerover イベントはバブリングしますが、 pointerenter イベントはバブリングしません。この点が最も重要な違いです。この違いを理解するために、次のサンプルをご覧ください。

サンプルのレンダリング結果
<div id="parent">
  <div id="child"></div>
</div>
const parent_el = document.getElementById('parent');
const child_el = document.getElementById('child');

parent_el.addEventListener('pointerover', showEvent);
parent_el.addEventListener('pointerenter', showEvent);

child_el.addEventListener('pointerover', showEvent);
child_el.addEventListener('pointerenter', showEvent);

function showEvent(event) {
  const log = `イベントタイプ: ${event.type}, `
    + `イベント発生源: ${event.target.id}, `
    + `リスナー要素: ${event.currentTarget.id}`;
  console.log(log);
}

このサンプルでは、入れ子になった div 要素が 2 つあります。それぞれの div 要素に対して、pointerover, pointerenter イベントのリスナーをセットしています。そして、どんな種類のイベントが、どの要素で発生し、どの要素のリスナーでそのイベントをキャッチしたのかをコンソールに出力しています。

では、マウスを使って試していましょう。まず、マウスポインターを外側の div 要素の外から中に移動します。すると、次のイベントが発生します。

イベントタイプ: pointerover, イベント発生源: parent, リスナー要素: parent
イベントタイプ: pointerenter, イベント発生源: parent, リスナー要素: parent

parent で pointerover と pointerenter イベントが発生し、parent のリスナーでそれらのイベントをキャッチしています。ここまでは期待通りでしょう。では、そのまま内側の div 要素の中にマウスポインターを移動しましょう。すると、次のイベントが発生します。

イベントタイプ: pointerover, イベント発生源: child, リスナー要素: child
イベントタイプ: pointerover, イベント発生源: child, リスナー要素: parent
イベントタイプ: pointerenter, イベント発生源: child, リスナー要素: child

pointerover と pointerenter イベントが child で 1 回ずつ発生しています(1 行目と 3 行目)。それらが child のリスナーでキャッチされることは理解できると思います。

ご存じの通り、多くのイベントはバブリングしますので、同じイベントを parent のリスナーでもキャッチできると期待してしまいます。ところが、この結果の通り、parent では child で発生したイベントのうち pointerover イベントのみをキャッチし、pointerenter イベントはキャッチできていません。つまり、pointerenter イベントは child からバブリングしていないということになります。

以上の話は pointerout と pointerleave イベントにも当てはまります。

座標を取得する方法

前述の pointerEvent のイベント情報のプロパティには、ポインターの座標を表すようなプロパティがありませんでした。しかし座標を知ることができないと困ることが多いことでしょう。

実は PointerEvent は MouseEvent を継承しています。つまり、MouseEvent にセットされていたプロパティは PointerEvent にもセットされています。

実際に PointerEvent のオブジェクトの中身を見てみましょう。

<div id="box"></div>
const box_el = document.getElementById('box');
box_el.addEventListener('pointerdown', (event) => {
  console.log(event);
});

このコードは、div 要素に対して pointerdown イベントのリスナーをセットしています。実際にマウスボタンを押下すると、ブラウザーコンソールに PointerEvent オブジェクトが出力されます。以下は、Windows 11 の Chrome で試した結果です。

御覧の通り、clientX/clientY、offsetX/offsetY、pageX/pageY、screenX/screenY などのおなじみの MouseEvent のプロパティが含まれていることが分かります。

マルチタッチの処理方法

PointerEvent はマルチタッチにも対処できます。そのキーとなるのがイベント情報の pointerId プロパティです。新たにポインターが生成されると(指でタッチすると)、ポインターを一意に区別できる ID が新しく割り振られます。ページがブラウザーのタブに存在し続ける限り、別のポインターに過去の ID が割り振られることはありません。

次の例は、二本の指でタッチして動かすと、リアルタイムに二点間の距離を表示します。

<div id="box">
  <span id="dist">0</span>
</div>
    const pointers = {};
    const box_el = document.getElementById('box');

    // PointerEvent のリスナーをセット
    box_el.addEventListener('gotpointercapture', (event) => {
      pointers[event.pointerId] = event;
      calcDistance();
    });

    box_el.addEventListener('lostpointercapture', (event) => {
      delete pointers[event.pointerId];
      calcDistance();
    });

    box_el.addEventListener('pointermove', (event) => {
      pointers[event.pointerId] = event;
      calcDistance();
    });

    // ピンチインとピンチアウトを抑止
    box_el.addEventListener('touchstart', (event) => {
      event.preventDefault();
    });

    // 2 点の距離を計算して表示
    function calcDistance() {
      if (Object.keys(pointers).length < 2) {
        return 0;
      }
      const pointer_list = Object.values(pointers);
      const x0 = pointer_list[0].clientX;
      const y0 = pointer_list[0].clientY;
      const x1 = pointer_list[1].clientX;
      const y1 = pointer_list[1].clientY;
      const dist = Math.sqrt(Math.pow((x0 - x1), 2) + Math.pow((y0 - y1), 2));
      document.getElementById('dist').textContent = Math.round(dist);
    }

ここでは、ポインター(指によるタッチ)の発生と消滅の判定に gotpointercapture イベントと lostpointercapture イベントを使っています。そして、pointermove イベントのリスナーによって、指を動かしてもリアルタイムに二点間の距離を算出するようにしています。

実際に割り振られる pointerId はポインターが生成される都度に値が増えていきますが、それを前提にコードを書いてはいけません。ブラウザーが pointerId の値をインクリメントするかどうかは保証されていませんので注意してください。

マルチタッチの上限の取得方法

近年のタッチデバイスは、同時に 2 本以上のタッチを受け入れます。ウェブアプリを開発する場合、マルチタッチの上限に応じて処理を分ける必要が出てくる場合があります。そのようなケースでは、navigator.maxTouchPoints からマルチタッチの上限を取得することができます。

const max = navigator.maxTouchPoints;
console.log(max);

筆者が持っているいくつかのデバイスで navigator.maxTouchPoints の値を調べたら、次のようになりました。

デバイス navigator.maxTouchPoints の値
Microsoft Surface Pro 810
Google Pixel 6 Pro5
Apple iPhone 13 Pro5
Apple iPad mini (第 6 世代)5

MouseEvent や TouchEvent はもう不要なのか?

PointerEvent はすべてのメジャーブラウザーでサポートされています。また、イベントの種類やイベントデータに不足はないような気がします。そのため、MouseEvent や TouchEvent はもう必要ないのではないかと思いたくなります。

しかし、実は PointerEvent では用意されていない機能が MouseEvent や TouchEvent には存在します。それらを必要とする場合は、今なお、MouseEvent や TouchEvent を使わざるを得ません。そのような機能は筆者が思いつくだけで 2 つあります。

まず 1 つ目です。それは click イベントです。ご存じの通り、click イベントはマウスのボタンを離したときに発生します。では、mouseup イベントとの違いは何でしょうか?それは、click イベントは同じ要素でマウスボタンを押下して離さないと発生しません。一方、mouseup イベントは、別のエリアでマウスボタンを押下し、押下したままマウスポインタを動かして、該当の要素上でボタンを離せば発生します。

このような特性から、click イベントはフォームのボタンなどに重宝してきました。しかし、PointerEvent には click イベントに相当するイベントがありません。もしマウスボタンを押した場所と離した場所が同じであることを必要とする場合は、click イベントが便利です。

次に 2 つ目です。それはピンチインやピンチアウトの抑止です。前述のサンプルでも使いましたが、touchstart イベントのデフォルトアクションを止めることで、ピンチインやピンチアウトを抑止することができます。

box_el.addEventListener('touchstart', (event) => {
  event.preventDefault();
});

残念ながら PinterEvent のイベントの中で、デフォルトアクションがピンチインやピンチアウトになっているイベントはありません。そのため、今なお、TouchEvent の一つである touchstart イベントを使わざるを得ません。

後述しますが、実はこの 2 つ目のケースでは別の解決方法も存在します。そのため、TouchEvent は必要がなくなるかもしれません。

CSS の touch-action プロパティでパンおよびピンチ操作を抑止する方法

前述の例では touchstart イベントのデフォルトアクションを止めることでピンチ操作を抑止しましたが、CSS でも制御可能です。touch-action プロパティに “none” を指定すると、ピンチ操作を止めることができます。

<div id="box" style="touch-action: none;"></div>

touch-action プロパティに指定可能な値は次の通りです。

説明
autoブラウザーのパン操作とピンチ操作を有効にします。
noneブラウザーのパン操作とピンチ操作を無効にします。
pan-xブラウザーの水平方向のパン操作を有効にします。
pan-yブラウザーの垂直方向のパン操作を有効にします。
manipulation ブラウザーのパン操作とピンチ操作を有効にしますが、標準でないブラウザーの操作(ダブルタップによるズームなど)を無効にします。

まとめ

以前はペンといえば、デザイナーさんがペンタブレットで使うもので、一般的なものではありませんでした。しかし、近年は Apple iPad などのタブレット、Microsoft Surface Pro のような 2 in 1 PC などのデバイスで、ペンは当たり前の入力デバイスになりました。Samsung Galaxy のようにペン入力を売りにしたスマートフォンまで登場しています。

今やウェブサイトやウェブアプリ開発ではペン入力を考慮しないわけにいきません。使い勝手の良いウェブサイトやウェブアプリ開発のためにも PointerEvent をマスターしておきたいところです。

今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。

Share