FIX: Improvements to animated image pausing (#12839)

FIX: Improvements to animated image pausing (#12839)

diff --git a/app/assets/javascripts/discourse/app/initializers/animated-images.js b/app/assets/javascripts/discourse/app/initializers/animated-images.js
index 1d3a77e..b4fab0b 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 c635edc..5cb6976 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 548093d..0fd5e21 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 d8b5ee7..4934329 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",

GitHub sha: 548c0448

This commit appears in #12839 which was approved by eviltrout. It was merged by pmusaraj.