LoginSignup
16
16

More than 5 years have passed since last update.

複数枚重なったcanvasで透明領域のマウスイベントを見えている直下の要素に渡したい

Last updated at Posted at 2015-10-20

複数枚重なったcanvasで透明領域のマウスイベントを見えている直下の要素に渡したい

canvas layers

こういう透明なcanvasが複数枚重なっていたとして、
canvasの不透明領域(図中の丸)ではcanvasにマウスイベントが発生して、
それ以外の透明領域ではマウス直下の要素にマウスイベントを発火させたいとする。

<div id="wrapper">
  <canvas id="cnv1"></canvas>
  <canvas id="cnv2"></canvas>
  <div id="rect"></div>
  <canvas id="cnv3"></canvas>
</div>
canvas {
  border: none;
}
#wrapper{
  position: relative;
  top: 20px;
  left: 20px;
  width: 100px;
  height: 100px;
  background-color: gray;
}
#wrapper>*{
  position: absolute;
}
#rect{
  top: 25px;
  left: 75px;
  width: 50px;
  height: 50px;
  background-color: yellow;
  border: 1px solid black;
}
#cnv1{
  top: 10px;
  left: 10px;
}
#cnv2{
  top: 20px;
  left: 20px;
}
#cnv3{
  top: 30px;
  left: 30px;
}

pointer-events

このとき、pointer-events
というCSSプロパティを使えばその要素にマウスイベントを発生させず透過させることができるが、canvasの不透明要素へのマウスイベントも当然発生しなくなる。故に今回の場合この方法は使えない。

座標の位置の要素を取得する

そこで次に考えるのがdocument.elementFromPointである。これは、vieportのx,y座標の位置の要素を取得できるAPIである。これとMouseEventのclientX,clientYを組み合わせれば、マウスポインタの座標の要素を取得できる。このAPIを使って、ある要素の背後の要素を取得するには次のようにする。

getUnderElement = (target, clientX, clientY)->
  tmp = target.style.display
  target.style.display = "none"
  under = document.elementFromPoint(clientX, clientY)
  target.style.display = tmp
  under

まず、マウスイベントが発生した要素targetのCSSのdisplayプロパティをnoneにしてレンダリングツリーから存在を消す。
そうしておいて、document.elementFromPoint(x, y)をすることで、その座標における、直下の要素を取得できる。
これは今回の目的に使えそうである。

参考:
"What is the difference between screenX/Y, clientX/Y and pageX/Y?"
http://stackoverflow.com/questions/6073505/what-is-the-difference-between-screenx-y-clientx-y-and-pagex-y

canvasのある座標が透明かどうか判定する

次に、canvasのマウスイベントが発生したその座標において、透明であるかそうでないかを判定する方法を考える。これにはcanvas要素のalpha channelを調べればよい。

isHit = (cnv, x, y)->
  ctx = cnv.getContext("2d")
  imgdata = ctx.getImageData(0, 0, x + 1, y + 1)
  imgdata.data[imgdata.data.length - 1] isnt 0

対象ピクセルの1x1のimagedataからalpha channelを読み込み、その領域が透明であるか判定できる。

直下の要素にマウスイベントを投げる

MouseEventコンストラクタとEventTarget#dispatchEventメソッドを使うことで、現在のマウスイベントの値を持った新しいマウスイベントを生成して、直下の要素underElementに対してマウスイベントを発火させることができる。

ev.preventDefault()
ev.stopPropagation()
mev = new MouseEvent ev.type,
  screenX: ev.screenX
  screenY: ev.screenY
  clientX: ev.clientX
  clientY: ev.clientY
  ctrlKey: ev.ctrlKey
  altKey:  ev.altKey
  shiftKey:ev.shiftKey
  metaKey: ev.metaKey
  button:  ev.button
  buttons: ev.buttons
  relatedTarget: ev.relatedTarget
  view:    ev.view
  detail:  ev.detail
  bubbles: false
underElement.dispatchEvent(mev);

ここまでが問題解決に必要な技術である。

複数枚重なったcanvasに対応する

canvasが複数枚重なっている場合、上の手法をそのまま使うだけでは、見えている要素にはイベントが発生しない。先ほど作ったgetUnderElement関数を思い出してほしい。

getUnderElement = (target, x, y)->
  tmp = target.style.display
  target.style.display = "none"
  under = document.elementFromPoint(x, y)
  target.style.display = tmp
  under

返り値を渡す前にtarget.style.display = tmpをして、レンダリングツリーに再表示している。これでは複数枚canvasが重なっている場合、1枚目と2枚目が互いにイベントを発生しあい、それより下にイベントが通知されなくなる。そのため、一回のイベント内で、再帰的に直下要素がcanvasであるかどうか、また、そのcanvasのその位置が透明であるかを判定する必要がある。そして、直下の要素がcanvasかつ不透明ならその当たりと判定し、透明かつcanvas以外の要素であったらマウスイベントを発火させる。

説明するのが面倒くさくなってきたので、最終的なコードだけ示す。

ids = ["cnv1", "cnv2", "cnv3"]
cnvs = ids.map (id)-> document.getElementById(id)
cnvs.forEach (cnv)-> cnv.width = cnv.height = 100
ctxs = cnvs.map (cnv)-> cnv.getContext("2d")
colors = ["red", "green", "blue"]

ctxs.forEach (ctx, i)->
  ctx.beginPath()
  ctx.arc(cnvs[i].width/2, cnvs[i].height/2, cnvs[i].width/4, 0, 2*Math.PI, true)
  ctx.closePath()
  ctx.fillStyle = colors[i%colors.length]
  ctx.fill()
  ctx.beginPath()
  ctx.rect(0, 0, 100, 100);
  ctx.closePath()
  ctx.stroke();

cnvs.forEach (cnv)->
  console.log cnv.id, $(cnv).offset()

$("#wrapper").on "click",  (ev)->
  console.log "wrapper" if ev.target is @

$("#wrapper").delegate "canvas", "click", (ev)->
  hit = recursiveElementFromPoint(ev, wrapper, ev.target)
  return unless hit?
  console.log "circle", hit

$("#rect").on "click", (ev)->
  console.log "rect"

recursiveElementFromPoint = (ev, parent, target)->
  {clientX, clientY, pageX, pageY} = ev
  {left, top} = $(target).offset()
  [offsetX, offsetY] = [pageX - left, pageY - top]
  if [].slice.call(wrapper.children).indexOf(target) > -1 and
     target instanceof HTMLCanvasElement and
     isHit(target, offsetX, offsetY)
    ev.preventDefault()
    ev.stopPropagation()
    return target
  tmp = target.style.display
  target.style.display = "none"
  under = document.elementFromPoint(clientX, clientY)
  unless under?
    target.style.display = tmp
    return null 
  if [].slice.call(wrapper.children).indexOf(under) > -1 and
     under instanceof HTMLCanvasElement
    result = recursiveElementFromPoint(ev, wrapper, under) # display:noneしたまま再帰的に直下要素を調べる。ここがミソ
    target.style.display = tmp
    return result
  target.style.display = tmp
  ev.preventDefault()
  ev.stopPropagation()
  eventPropagationSim(under, ev)
  return null

isHit = (cnv, x, y)->
  ctx = cnv.getContext("2d")
  imgdata = ctx.getImageData(0, 0, x + 1, y + 1)
  imgdata.data[imgdata.data.length - 1] isnt 0

eventPropagationSim = (target, ev)->
  if ev.type is "click" or
     /^mouse/.test(ev.type)
    ev.preventDefault()
    ev.stopPropagation()
    mev = new MouseEvent ev.type,
      screenX: ev.screenX
      screenY: ev.screenY
      clientX: ev.clientX
      clientY: ev.clientY
      ctrlKey: ev.ctrlKey
      altKey:  ev.altKey
      shiftKey:ev.shiftKey
      metaKey: ev.metaKey
      button:  ev.button
      buttons: ev["buttons"]
      relatedTarget: ev.relatedTarget
      view:    ev["view"]
      detail:  ev["detail"]
      bubbles: false
    target.dispatchEvent(mev);
  else
   console.log(ev.type)
  return
16
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
16