From 548c044809b4370517da0c6e39dd12e06bc4b320 Mon Sep 17 00:00:00 2001 From: Penar Musaraj Date: Wed, 28 Apr 2021 10:48:00 -0400 Subject: [PATCH] FIX: Improvements to animated image pausing (#12839) --- .../app/initializers/animated-images.js | 107 +++++++++++++++--- .../discourse/app/lib/utilities.js | 4 + .../stylesheets/common/base/topic-post.scss | 54 ++++++++- lib/svg_sprite/svg_sprite.rb | 1 + 4 files changed, 143 insertions(+), 23 deletions(-) diff --git a/app/assets/javascripts/discourse/app/initializers/animated-images.js b/app/assets/javascripts/discourse/app/initializers/animated-images.js index 1d3a77e2fd..b4fab0be9d 100644 --- a/app/assets/javascripts/discourse/app/initializers/animated-images.js +++ b/app/assets/javascripts/discourse/app/initializers/animated-images.js @@ -1,35 +1,53 @@ +import { iconHTML } from "discourse-common/lib/icon-library"; +import { prefersReducedMotion } from "discourse/lib/utilities"; import { withPluginApi } from "discourse/lib/plugin-api"; let _gifClickHandlers = {}; +function _pauseAnimation(img, opts = {}) { + let canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + canvas.getContext("2d").drawImage(img, 0, 0, img.width, img.height); + canvas.setAttribute("aria-hidden", "true"); + canvas.setAttribute("role", "presentation"); + + if (opts.manualPause) { + img.classList.add("manually-paused"); + } + img.parentNode.classList.add("paused-animated-image"); + img.parentNode.insertBefore(canvas, img); +} + +function _resumeAnimation(img) { + img.previousSibling.remove(); + img.parentNode.classList.remove("paused-animated-image", "manually-paused"); +} + +function animatedImgs() { + return document.querySelectorAll("img.animated:not(.manually-paused)"); +} + export default { name: "animated-images-pause-on-click", initialize() { withPluginApi("0.8.7", (api) => { function _cleanUp() { - Object.values(_gifClickHandlers || {}).forEach((handler) => - handler.removeEventListener("click", _handleClick) - ); + Object.values(_gifClickHandlers || {}).forEach((handler) => { + handler.removeEventListener("click", _handleEvent); + handler.removeEventListener("load", _handleEvent); + }); _gifClickHandlers = {}; } - function _handleClick(event) { + function _handleEvent(event) { const img = event.target; if (img && !img.previousSibling) { - let canvas = document.createElement("canvas"); - canvas.width = img.width; - canvas.height = img.height; - canvas.getContext("2d").drawImage(img, 0, 0, img.width, img.height); - canvas.setAttribute("aria-hidden", "true"); - canvas.setAttribute("role", "presentation"); - - img.parentNode.classList.add("paused-animated-image"); - img.parentNode.insertBefore(canvas, img); + _pauseAnimation(img, { manualPause: true }); } else { - img.previousSibling.remove(); - img.parentNode.classList.remove("paused-animated-image"); + _resumeAnimation(img); } } @@ -41,17 +59,37 @@ export default { let images = post.querySelectorAll("img.animated"); images.forEach((img) => { + // skip for edge case of multiple animated images in same block + if (img.parentNode.querySelectorAll("img").length > 1) { + return; + } + if (_gifClickHandlers[img.src]) { _gifClickHandlers[img.src].removeEventListener( "click", - _handleClick + _handleEvent + ); + _gifClickHandlers[img.src].removeEventListener( + "load", + _handleEvent ); - delete _gifClickHandlers[img.src]; } _gifClickHandlers[img.src] = img; - img.addEventListener("click", _handleClick, false); + img.addEventListener("click", _handleEvent, false); + + if (prefersReducedMotion()) { + img.addEventListener("load", _handleEvent, false); + } + + img.parentNode.classList.add("pausable-animated-image"); + const overlay = document.createElement("div"); + overlay.classList.add("animated-image-overlay"); + overlay.setAttribute("aria-hidden", "true"); + overlay.setAttribute("role", "presentation"); + overlay.innerHTML = `${iconHTML("pause")}${iconHTML("play")}`; + img.parentNode.appendChild(overlay); }); } @@ -61,6 +99,39 @@ export default { }); api.cleanupStream(_cleanUp); + + // paused on load when prefers-reduced-motion is active, no need for blur/focus events + if (!prefersReducedMotion()) { + window.addEventListener("blur", this.blurEvent); + window.addEventListener("focus", this.focusEvent); + } }); }, + + blurEvent() { + animatedImgs().forEach((img) => { + if ( + img.parentNode.querySelectorAll("img").length === 1 && + !img.previousSibling + ) { + _pauseAnimation(img); + } + }); + }, + + focusEvent() { + animatedImgs().forEach((img) => { + if ( + img.parentNode.querySelectorAll("img").length === 1 && + img.previousSibling + ) { + _resumeAnimation(img); + } + }); + }, + + teardown() { + window.removeEventListener("blur", this.blurEvent); + window.removeEventListener("focus", this.focusEvent); + }, }; diff --git a/app/assets/javascripts/discourse/app/lib/utilities.js b/app/assets/javascripts/discourse/app/lib/utilities.js index c635edcc2b..5cb6976d5d 100644 --- a/app/assets/javascripts/discourse/app/lib/utilities.js +++ b/app/assets/javascripts/discourse/app/lib/utilities.js @@ -433,6 +433,10 @@ export function isiOSPWA() { return window.matchMedia("(display-mode: standalone)").matches && caps.isIOS; } +export function prefersReducedMotion() { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + export function isAppWebview() { return window.ReactNativeWebView !== undefined; } diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss index 548093d4da..0fd5e21b5d 100644 --- a/app/assets/stylesheets/common/base/topic-post.scss +++ b/app/assets/stylesheets/common/base/topic-post.scss @@ -1240,15 +1240,59 @@ a.mention-group { } } -.paused-animated-image { +.pausable-animated-image { position: relative; - display: block; - > canvas { + display: inline-block; + + > canvas, + > .animated-image-overlay { position: absolute; - top: 0; - left: 0; + bottom: 0; + right: 0; } + > canvas { + background-color: var(--primary-very-low); + } + + > .animated-image-overlay { + pointer-events: none; + text-align: right; + display: flex; + justify-content: flex-end; + align-items: flex-end; + > .d-icon { + cursor: pointer; + padding: 0.5em; + margin: 0.5em; + background-color: rgba(var(--always-black-rgb), 0.25); + color: var(--secondary-or-primary); + cursor: pointer; + display: none; + } + } + + img.animated { + cursor: pointer; + } + + html.no-touch + &:not(.paused-animated-image) + .animated:hover + + .animated-image-overlay + .d-icon-pause { + display: initial; + } + + &.paused-animated-image + .animated.manually-paused + + .animated-image-overlay + .d-icon-play { + display: initial; + } +} + +.paused-animated-image { img.animated { // need to keep the image hidden but clickable // so the user can resume animation diff --git a/lib/svg_sprite/svg_sprite.rb b/lib/svg_sprite/svg_sprite.rb index d8b5ee7470..49343298d9 100644 --- a/lib/svg_sprite/svg_sprite.rb +++ b/lib/svg_sprite/svg_sprite.rb @@ -154,6 +154,7 @@ module SvgSprite "moon", "paint-brush", "paper-plane", + "pause", "pencil-alt", "play", "plug",