FEATURE: Allow pausing animated images in posts (#12795)

FEATURE: Allow pausing animated images in posts (#12795)

Co-authored-by: Jarek Radosz jradosz@gmail.com

diff --git a/app/assets/javascripts/discourse/app/initializers/animated-images.js b/app/assets/javascripts/discourse/app/initializers/animated-images.js
new file mode 100644
index 0000000..1d3a77e
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/initializers/animated-images.js
@@ -0,0 +1,66 @@
+import { withPluginApi } from "discourse/lib/plugin-api";
+
+let _gifClickHandlers = {};
+
+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)
+        );
+
+        _gifClickHandlers = {};
+      }
+
+      function _handleClick(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);
+        } else {
+          img.previousSibling.remove();
+          img.parentNode.classList.remove("paused-animated-image");
+        }
+      }
+
+      function _attachCommands(post, helper) {
+        if (!helper) {
+          return;
+        }
+
+        let images = post.querySelectorAll("img.animated");
+
+        images.forEach((img) => {
+          if (_gifClickHandlers[img.src]) {
+            _gifClickHandlers[img.src].removeEventListener(
+              "click",
+              _handleClick
+            );
+
+            delete _gifClickHandlers[img.src];
+          }
+
+          _gifClickHandlers[img.src] = img;
+          img.addEventListener("click", _handleClick, false);
+        });
+      }
+
+      api.decorateCookedElement(_attachCommands, {
+        onlyStream: true,
+        id: "animated-images-pause-on-click",
+      });
+
+      api.cleanupStream(_cleanUp);
+    });
+  },
+};
diff --git a/app/assets/stylesheets/common/base/topic-post.scss b/app/assets/stylesheets/common/base/topic-post.scss
index 6fb2ec9..85dcb3d 100644
--- a/app/assets/stylesheets/common/base/topic-post.scss
+++ b/app/assets/stylesheets/common/base/topic-post.scss
@@ -1233,3 +1233,19 @@ a.mention-group {
     @include ellipsis;
   }
 }
+
+.paused-animated-image {
+  position: relative;
+  display: block;
+  > canvas {
+    position: absolute;
+    top: 0;
+    left: 0;
+  }
+
+  img.animated {
+    // need to keep the image hidden but clickable
+    // so the user can resume animation
+    opacity: 0;
+  }
+}
diff --git a/lib/cooked_post_processor.rb b/lib/cooked_post_processor.rb
index 6197c43..ed57f8b 100644
--- a/lib/cooked_post_processor.rb
+++ b/lib/cooked_post_processor.rb
@@ -318,6 +318,12 @@ class CookedPostProcessor
       return
     end
 
+    upload = Upload.get_from_url(src)
+
+    if upload.present? && upload.animated?
+      img.add_class("animated")
+    end
+
     return if original_width <= SiteSetting.max_image_width && original_height <= SiteSetting.max_image_height
 
     user_width, user_height = [original_width, original_height] if user_width.to_i <= 0 && user_height.to_i <= 0
@@ -332,7 +338,6 @@ class CookedPostProcessor
       width, height = ImageSizer.resize(width, height)
     end
 
-    upload = Upload.get_from_url(src)
     if upload.present?
       upload.create_thumbnail!(width, height, crop: crop)
 
diff --git a/spec/components/cooked_post_processor_spec.rb b/spec/components/cooked_post_processor_spec.rb
index 6117d88..7cffe18 100644
--- a/spec/components/cooked_post_processor_spec.rb
+++ b/spec/components/cooked_post_processor_spec.rb
@@ -973,7 +973,7 @@ describe CookedPostProcessor do
       expect(doc.css('img').first['srcset']).to_not eq(nil)
     end
 
-    it "does not optimize animated images" do
+    it "does not optimize animated images but adds a class so animated images can be identified" do
       upload.update!(animated: true)
       post = Fabricate(:post, raw: "![image|1024x768, 50%](#{upload.short_url})")
 
@@ -984,6 +984,7 @@ describe CookedPostProcessor do
       expect(doc.css('.lightbox-wrapper').size).to eq(1)
       expect(doc.css('img').first['src']).to include(upload.url)
       expect(doc.css('img').first['srcset']).to eq(nil)
+      expect(doc.css('img.animated').size).to eq(1)
     end
 
     it "optimizes images in quotes" do

GitHub sha: c11d75da

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

This commit has been mentioned on Discourse Meta. There might be relevant details there: