おひさしぶりです。この記事はクソアプリ Advent Calendar 2021の13日目です
突然ですが皆さんは「スクロールに合わせてふわっと出てくるwebページ」ってどう思います? 最近多いですよね、あれ。
確かにオシャレだし楽しいしゲームとか作家さんのギャラリーサイトとかなら全然OK。でも情報が欲しくてアクセスしてるページで「ふわっ」ってされるとちょっとイラッとする。
ちょっと?...イラッと?...いや、 許さない、絶対
よろしい、ならば粉砕だ
よそ様のサイトを粉砕する都合上、今回の実装はChrome機能拡張です。機能拡張はViteにChrome機能拡張用のプラグインvite-plugin-chrome-extensionを入れて作りました。今回は解説しないけど、これ超楽。フレームワークは無し、言語はTypeScriptです。
とは言え、クソアプリのためにChromeに機能拡張をインストールしてくれる酔狂な方は少ないと思うので、インストール不要のデモページも作りました。ページ開いてスクロールすると自動でゲームが始まります。
- ページ内で検出されたアニメーションに赤枠がつきます
- アニメーション中にクリックすれば粉砕できます
- 粉砕すると大きさに応じてなんとなくポイントがつきます。
- クリアもゲームオーバーもありません。だってクソアプリだもの
- もう一度プレイしたい時は、一番上までスクロールを戻してからリロードしてください。(だってks(略)
実物のwebサイトで動作させた様子も載せておきます。これ、どこのサイトでやってもどう考えてもカドが立つので弊社採用サイトでやりました。これはこれで怒られそうな気もするけど、「弊社只今、webが好きな熱いエンジニア積極募集中です!!」って書いておけば許してもらえるかもしれない。気になる方はDMください。
以下、ちょっと技術的なお話です
全てのアニメーションを検出したい
全てのアニメーションを粉砕するには、まず全てのアニメーションを検出しないといけません。自分の作ったコンテンツなら簡単だけど、よそ様のサイトはどんなフレームワークやライブラリを使っているかもわからないので、ちょっと工夫が必要です。
1. CSS Transition / CSS Animationを検出する
webページをアニメーションさせる一番手軽な方法がCSS TransitionとCSS Animationです。カーソルのせるとちょっと動いたり、スクロールでふわっと出てくるくらいならこれが一番楽。
で、こいつらを検出する方法ですが、CSS TransitionとCSS Animationにはそれぞれtransitionstart
とanimationstart
ってイベントがあるのでこれを拾います。このイベントもmousedown
みたいなよくあるイベント同様にバブリングでイベントが伝播します。さらにこれをわざわざ握りつぶすことも普通はしないはず。つまり、↓のようにbody
で網を張っていれば全て補足できます。
const onstart = (ev) => {
console.log('transition started!', ev.target)
// 赤枠をつける
ev.target.style.outline = '2px solid red'
}
document.body.addEventListener('transitionstart', onstart)
document.body.addEventListener('animationstart', onstart)
同様にアニメーションの終了はtransitionend
とanimationend
イベントで拾えます。よって、
-
transitionstart
/animationstart
で要素を粉砕対象に追加 -
transitionend
/animationend
で要素を粉砕対象から削除
とすれば、動いているものだけを粉砕の対象にできそうです。やったね!
...って思うじゃん?
2. Transition / Animationを使わずに動かしているやヤツを検出する
実際上のコードで赤枠をつけてみるとわかるのですが、サイトによってはこの方法では検出できないアニメーションが結構あります。一番よくあるのが、setInterval
やrequestAnimationFrame
を使っているパターン。
const el = document.getElementById('neko')
const move = () => {
const sec = Date.now() / 1000
const y = Math.sin(sec) * 100
el.style.transform = `translateY(${y}px)`
requestAnimationFrame(move)
}
move()
基本的にはCSS Transition / CSS Animationを使った方がパフォーマンスは良いのだけど、複雑な動かし方や制御が必要なケースではsetInterval
/ requestAnimationFrame
方式も結構使われます。GSAPみたいなアニメーションライブラリも大抵はこのパターンのはず。
で、このパターンは結局こまめにスタイルを書き換えているだけなので、transitionstart
/ animationstart
みたいなイベントも発火しないわけで、そのままでは検出ができません。
そこで登場するのが**困った時の[MutationObserver] (https://developer.mozilla.org/ja/docs/Web/API/MutationObserver)**です。この子、IEですらサポートしてる標準機能のわりに滅多に登場することのない影の子なのだけど、こういうケースでは**救世主**になるので覚えておいて損はないです。
const observer = new MutationObserver(function (mutations) {
const els = mutations.forEach((rec) => {
console.log('styleが変わりました', rec.target)
})
})
observer.observe(document.body, {
subtree: true, // 子孫要素全てを監視する
attributes: true, // 監視対象 = 属性
attributeFilter: ['style'], // 監視するのはstyleのみ
})
とはいえ、これで拾えるのは「要素のstyle
が変わった」ことだけなので、そこからさらにフィルタして「transform
やwidth
といったアニメーション系のプロパティが変わったかどうか」をチェックします。
-
transform
やwidth
といったアニメーション系のプロパティが変わったらそれを記録 - 一定時間内に再度プロパティが変わったら「アニメーション開始」と見做して粉砕対象に追加
- 一定時間変化がなければ「アニメーション終了」と見做して粉砕対象から削除
整理するとこんな流れです。面倒だけどやることはシンプルですね
実際のソースでは「1. CSS Transition / CSS Animation」方式と「2. タイマー / requestAnimationFrame」方式の2つの結果をマージして、1つのインタフェースで粉砕対象の追加/削除を受け取れるように整えています。
watchAllAnimatiedElements((added, removed, all) => {
added.map((element) => {
console.log('アニメーション開始', element.el)
})
removed.map((element) => {
console.log('アニメーション終了', element.el)
})
})
これで粉砕すべき要素のリストは手に入りました。次はこれを実際に粉砕します。
なお、レアキャラとして以下のようなアニメーションもあるのですが、今回は見ないふりしてます。
- SVGの
<animate>
要素を使ったアニメーション
→ これも頑張ればイベントが拾えるはず。今回は面倒なのでパス - Canvasを使ったアニメーション
→ これはムリ。やろうとすると実際の描画内容を画像として解析しないといけなくなるはず
全てのアニメーションをクリックで粉砕したい
細かいことは置いておいて、敵のリストが手に入ったのでこれをクリックで粉砕する仕組みを作ります。
1. click
イベントで粉砕
これは超簡単。アニメーションが始まったらイベントハンドラをセットして終わったら削除するだけ。
// クリックした時のイベントハンドラ
const onDirectClick = (ev) => {
ev.preventDefault()
ev.stopImmediatePropagation()
ev.target.removeEventListener('click', onDirectClick)
element.el.style.outline = '2px solid blue'
}
// 粉砕対象の追加・削除を監視
watchAllAnimatiedElements((added, removed, all) => {
added.map((element) => {
console.log('アニメーション開始', element.el)
element.el.style.outline = '2px solid red'
element.el.addEventListener('click', onDirectClick)
})
removed.map((element) => {
console.log('アニメーション終了', element.el)
element.el.style.outline = ''
element.el.removeEventListener('click', onDirectClick)
})
})
イベントハンドラでは枠の色を変えて、それ以上イベントが伝播しないようにstopImmediatePropagation()
を呼びます。あと、要素が<a>
タグだったりすると画面遷移してしまうので、粉砕時にはリンクが動かないようpreventDefault()
でブロックしておきます。
...いや、これでいけると思ったんだ最初は。
2. elementsFromPoint()
でクリック座標の要素を粉砕
実際には、これで拾えないケースもちょくちょく。。ありがちなのが上に別な要素が乗っちゃってるパターン。手前に文字があって、背景だけふわっと出てくるようなデザインですね。
これをなんとか拾うため、またしてもbody
要素に網を張ります。
// アニメーション中の要素 = 粉砕対象のリスト
let movingElems = []
// 粉砕対象の追加・削除を監視
watchAllAnimatiedElements((added, removed, all) => {
movingElems = [...all]
// 略
})
// bodyで網を張る
document.body.addEventListener('pointerdown', (ev) => {
// クリック位置の下にある全ての要素を取得
const elsAtPoint = elementsFromPoint(ev.clientX, ev.clientY)
// 粉砕対象の全要素
const elsMoving = movingElems.map((element) => element.el)
// 粉砕対象 & クリック位置の下にある要素を抽出
const els = elsAtPoint.filter((el) => elsMoving.includes(el))
// 見つかったものを粉砕
els.forEach((el) => el.style.outline = '2px solid blue')
})
これだけみると簡単ですね。elementsFromPoint()
でクリック座標にある全ての要素を取得し、その中に粉砕対象があればまとめて全部粉砕するだけです。
問題は「element s FromPoint()」なんて便利な関数が存在しないことです。クリック位置の一番上にある要素を取得するelementFromPoint()(elementsじゃなくてelement)は標準で存在するけど、クリック位置の全ての要素を取得する関数はないのです。
OK、無いなら作るしかない。
で、できたのがこれ↓ 作るって言ったけどstackoverflowで見つけた回答の焼き直しです。
export const elementsFromPoint = (
x: number,
y: number
): (HTMLElement | SVGElement)[] => {
const rollbackList: {
el: HTMLElement | SVGElement
pointerEvents: string
}[] = []
const results: (HTMLElement | SVGElement)[] = []
let current: Element | null = document.elementFromPoint(x, y)
while (current) {
if (current.tagName.toLowerCase() === 'html') break
if (!(current instanceof HTMLElement || current instanceof SVGElement)) {
break
}
results.push(current)
rollbackList.push({
el: current,
pointerEvents: current.style.pointerEvents,
})
current.style.pointerEvents = 'none'
current = document.elementFromPoint(x, y)
}
rollbackList.forEach(
(ent) => (ent.el.style.pointerEvents = ent.pointerEvents)
)
return results
}
読んでもらえた人は「まじかよ...」って思ったかもしないけど、わたしも「まじかよ...」って思った。
読む気のない人向けにざっくり説明すると、標準のelementFromPoint()
(クリック位置の一番上にある要素を取得する)を使って、
- クリック座標の一番上の要素を取得
- 見つかった要素の
style.pointerEvents
をnone
に変えてクリックに反応しないようにする - 1-2を繰り返す
- 一番下の
<html>
要素にぶつかったら終了 - 書き換えた
style.pointerEvents
を元の値に戻す - 見つかった全要素を返す
って流れ。こういうベタベタなコード書いたのは10年ぶりくらいな気がする...
ともあれ、これで大抵のパターンで粉砕対象のクリックができるようになりました。
クリックしたら見た目もちゃんと粉砕したい
ここまでで概ね目的は達しました。満足
最後はおまけで、クリック時にそれっぽく粉砕する演出を追加します。
Element.animate()
でエフェクトを追加する
ここまでの例では単にel.style.outline = '2px solid blue'
で青枠を描いただけだったけど、実際には↓こんな感じのコードになってます:
export const showHitEffect = (el) => {
el.animate(
[
{
opacity: el.style.opacity,
transform: 'scale(0.8)',
filter: `blur(0) url('#${NOISE_FILTER_ID}')`,
},
{
opacity: 0,
transform: 'scale(1.5)',
filter: `blur(10px) url('#${NOISE_FILTER_ID}')`,
},
],
{
fill: 'forwards',
duration: 500,
}
)
}
animate()
はCSS Animationと同様以上のことができるわりと新し目のAPI(Web Animations API)です。主要ブラウザではほぼ使えます。これを使ってopacity
やfilter
を調整して粉砕する演出を作ります。
単純にCSSのクラス名を追加して演出内容はCSS側で定義する形でも動きはしますが、(元の)webページ側でel.className = 'hoge'
のようにクラスを書き換えされてしまうと一発で演出が解除されてしまうため、あまり良い方法ではありません。今回使ったanimate()
によるスタイルの変更はCSS側の指定よりも常に強いので絶対粉砕する強い意志を示すのには最適です。
SVGフィルタで粉砕感を上げる
transform
やfilter: blur()
だけではイマイチ粉砕感が足りないので、今回は仕上げにSVGフィルターを当てます。上のanimate()
でfilter
プロパティに設定しているものですね。
自作のフィールターは↓こんな感じ。ちょっと雑だけど、<feTurbulence>
と<feDisplacementMap>
で入力イメージにすりガラスっぽいノイズを当てるSVGフィルタを作ってbody
直下に追加しておきます。
// filterを定義するSVG要素を作ってドキュメントに追加する
export const initSvgFilter = () => {
const NS = 'http://www.w3.org/2000/svg'
const el = document.createElementNS(NS, 'svg')
el.innerHTML = `
<filter id='${NOISE_FILTER_ID}' x='0%' y='0%' width='110%' height='110%'>
<feTurbulence type="turbulence" baseFrequency="0.2 0.2" result="NOISE" numOctaves="2" />
<feDisplacementMap in="SourceGraphic" in2="NOISE" scale="16" xChannelSelector="R" yChannelSelector="R" />
</filter>`
document.body.appendChild(el)
}
最初に定義しておけば、あとはElement.animate()
やCSS定義からfilter: url('ID')
の形で定義したフィルタを適用できます。フィルタの指定は残念ながらID属性を使う他ないので、この手の機能拡張で使う場合には元ページのコンテンツと重複しないよう、長めの名前をつけた方が良いです。
おまけ: 動きの多いサイトはprefers-reduced-motion
も使おう
余談だけど、「マナーよく作られたサイト」であれば、実は粉砕せずとも演出用のアニメーションを止めることができます。MacやiPhoneであれば「視差効果を減らす」をオンにすることで、Windowsの場合は「Windowsにアニメーションを表示する」をオフにすることで「あんまり動かさないで!」って宣言することができます。
(Windowsは試してないので違ってたら教えてください)
これが設定されているかどうかはJSやCSSのメディアクエリで拾えるので、webサイトを作るときは、これを見て必須ではないアニメーションを切ってしまうのが親切です。↓みたいに一律OFFにしちゃう(正確には極めて短時間で終わるアニメーションにしてしまう)のもあり。冒頭で出した弊社サイトもだいたいこんな感じになってます。
/* 「視差効果を減らす」がオンならアニメーションしない */
@media (prefers-reduced-motion: reduce) {
animation-delay: 0s !important;
animation-duration: 1ms !important;
transition-delay: 0s !important;
transition-duration: 1ms !important;
}
アニメーションは用法容量を守って使おう
さもないと粉砕するよ!