diff --git a/app/assets/images/pause.svg b/app/assets/images/pause.svg
new file mode 100644
index 000000000..3cac9e450
--- /dev/null
+++ b/app/assets/images/pause.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/images/play.svg b/app/assets/images/play.svg
new file mode 100644
index 000000000..0e5e96fb6
--- /dev/null
+++ b/app/assets/images/play.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/assets/stylesheets/article-show.scss b/app/assets/stylesheets/article-show.scss
index 6e8c04b95..b2afa07a2 100644
--- a/app/assets/stylesheets/article-show.scss
+++ b/app/assets/stylesheets/article-show.scss
@@ -551,3 +551,74 @@ article {
}
}
}
+
+// This classname is added by the freezeframe library we use to play/pause animated images
+.ff-container {
+ --gif-bg: rgb(var(--black));
+ --gif-color: rgb(var(--white));
+
+ left: 50%;
+ transform: translateX(-50%);
+
+ // Align styles of freezeframed animated images with normal image styles
+ canvas {
+ border-radius: var(--radius);
+ object-fit: contain;
+ max-width: 100%;
+ min-width: 100%;
+ max-height: calc(50vh + 180px);
+ }
+
+ img[data-animated='true'] {
+ margin: 0;
+ cursor: pointer;
+ }
+
+ .gif-button {
+ --bg: var(--gif-bg);
+ --bg-hover: var(--gif-bg);
+ --color: var(--gif-color);
+ --color-hover: var(--gif-color);
+
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ margin: var(--su-1);
+ z-index: var(--z-sticky);
+ border-radius: var(--radius);
+ display: flex;
+ align-items: center;
+ padding: var(--su-1) var(--su-2);
+ font-weight: var(--fw-bold);
+
+ &:hover:enabled,
+ .js-focus-visible &.focus-visible:focus,
+ &[aria-pressed='true'] {
+ --bg: var(--gif-bg);
+ --bg-hover: var(--gif-bg);
+ --color: var(--gif-color);
+ --color-hover: var(--gif-color);
+ z-index: var(--z-sticky);
+ }
+ }
+
+ .gif-button[aria-pressed='false'] {
+ .gif-pause {
+ display: inline-block;
+ }
+
+ .gif-play {
+ display: none;
+ }
+ }
+
+ .gif-button[aria-pressed='true'] {
+ .gif-pause {
+ display: none;
+ }
+
+ .gif-play {
+ display: inline-block;
+ }
+ }
+}
diff --git a/app/assets/stylesheets/base/reset.scss b/app/assets/stylesheets/base/reset.scss
index a2f5c2c46..5ba5f9fff 100644
--- a/app/assets/stylesheets/base/reset.scss
+++ b/app/assets/stylesheets/base/reset.scss
@@ -160,7 +160,9 @@ sup {
animation-iteration-count: 1 !important;
background-attachment: initial !important;
scroll-behavior: auto !important;
- transition-duration: 0s !important;
transition-delay: 0s !important;
+ // A transition duration of 0s will prevent transition start/end events from firing
+ // We use 0.001s instead to ensure no unintended consequences, while still effectively stopping the animation
+ transition-duration: 0.001s !important;
}
}
diff --git a/app/assets/stylesheets/config/_colors.css b/app/assets/stylesheets/config/_colors.css
index a6e65d90d..56a66422b 100644
--- a/app/assets/stylesheets/config/_colors.css
+++ b/app/assets/stylesheets/config/_colors.css
@@ -251,7 +251,7 @@
--tooltip-bg: rgba(var(--grey-900), 0.9);
--tooltip-color: rgb(var(--white));
-
+
/***********************************************
** Indicators **********************************
***********************************************/
diff --git a/app/javascript/packs/articlePage.jsx b/app/javascript/packs/articlePage.jsx
index ab844c926..8ddeda9ee 100644
--- a/app/javascript/packs/articlePage.jsx
+++ b/app/javascript/packs/articlePage.jsx
@@ -6,6 +6,15 @@ import { embedGists } from '../utilities/gist';
/* global Runtime */
+const animatedImages = document.querySelectorAll('[data-animated="true"]');
+if (animatedImages.length > 0) {
+ import('@utilities/animatedImageUtils').then(
+ ({ initializePausableAnimatedImages }) => {
+ initializePausableAnimatedImages(animatedImages);
+ },
+ );
+}
+
const fullscreenActionElements = document.getElementsByClassName(
'js-fullscreen-code-action',
);
diff --git a/app/javascript/utilities/animatedImageUtils.jsx b/app/javascript/utilities/animatedImageUtils.jsx
new file mode 100644
index 000000000..f7bb8540c
--- /dev/null
+++ b/app/javascript/utilities/animatedImageUtils.jsx
@@ -0,0 +1,91 @@
+import { h, render } from 'preact';
+import Freezeframe from 'freezeframe';
+import { Icon, ButtonNew as Button } from '@crayons';
+import Play from '@images/play.svg';
+import Pause from '@images/pause.svg';
+
+const toggleGifButtonPressedState = (button) => {
+ const currentlyPressed = button.getAttribute('aria-pressed') === 'true';
+ button.setAttribute('aria-pressed', !currentlyPressed);
+};
+
+/**
+ * Helper function that will initialize play/pause functionality for any image with the attribute data-animated="true"
+ */
+export const initializePausableAnimatedImages = (animatedImages = []) => {
+ if (animatedImages.length > 0) {
+ const freezeframes = [];
+
+ // Remove the surrounding links for the image, so it can be clicked to play/pause
+ for (const image of animatedImages) {
+ image.closest('a').outerHTML = image.outerHTML;
+
+ freezeframes.push(
+ new Freezeframe({
+ selector: `img[src="${image.getAttribute('src')}"]`,
+ responsive: false,
+ trigger: 'click',
+ }),
+ );
+ }
+
+ const handleFreezeframeReadyState = (_mutationList, observer) => {
+ // Check if freezeframe has finished initializing
+ const initializedFrames = document.querySelectorAll(
+ '.ff-container.ff-ready',
+ );
+
+ if (initializedFrames.length < freezeframes.length) {
+ // Not ready yet, do nothing
+ return;
+ }
+
+ // Freezeframes are ready, and we can disconnect our observer
+ observer.disconnect();
+
+ // Freezeframes are "paused" by default. If a user's settings allow, we immediately restart the animation.
+ const okWithMotion = window.matchMedia(
+ '(prefers-reduced-motion: no-preference)',
+ ).matches;
+
+ freezeframes.forEach((ff) => {
+ if (okWithMotion) {
+ ff.start();
+ }
+
+ // Freezeframe doesn't allow gifs to be stopped by keyboard press, so we add a button to handle it
+ const ffWrapper = ff.items[0]['$container'];
+
+ // We want to update the button's state when the gif is paused by image/canvas click
+ ffWrapper.addEventListener('click', ({ currentTarget }) =>
+ toggleGifButtonPressedState(
+ currentTarget.querySelector('.gif-button'),
+ ),
+ );
+
+ render(
+ ,
+ ffWrapper,
+ );
+ });
+ };
+
+ // Freezeframe initializes asynchronously, but unfortunately doesn't offer a callback for when it's "ready".
+ // This mutation observer allows us to complete some final tasks when it's complete.
+ const readyWatcher = new MutationObserver(handleFreezeframeReadyState);
+ readyWatcher.observe(document.querySelector('main'), {
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['class'],
+ });
+ }
+};
diff --git a/cypress/integration/seededFlows/articleFlows/playOrPauseAnimatedImages.spec.js b/cypress/integration/seededFlows/articleFlows/playOrPauseAnimatedImages.spec.js
new file mode 100644
index 000000000..19a4bbce6
--- /dev/null
+++ b/cypress/integration/seededFlows/articleFlows/playOrPauseAnimatedImages.spec.js
@@ -0,0 +1,109 @@
+describe('play or pause animated images', () => {
+ // Animated images are only identified async in the backend, and we can't rely on seeding them in the E2E DB
+ // Instead, we inject 3 images into the page body for testing purposes. 2 with the data-animated attribute, and 1 without.
+ const generateFakePageBody = () => {
+ const fakeBody = document.createElement('body');
+ const fakeMain = document.createElement('main');
+
+ for (let i = 0; i < 3; i++) {
+ const link = document.createElement('a');
+ link.setAttribute('href', '/test-link');
+
+ const image = document.createElement('img');
+ image.setAttribute('src', '/media/test-image.gif');
+ // Create two animated images, and one not
+ if (i < 2) {
+ image.setAttribute('data-animated', 'true');
+ }
+
+ link.appendChild(image);
+ fakeMain.appendChild(link);
+ }
+
+ fakeBody.appendChild(fakeMain);
+
+ return fakeBody;
+ };
+
+ describe('no reduced motion preference', () => {
+ beforeEach(() => {
+ Cypress.on('window:before:load', (window) => {
+ window.document.body = generateFakePageBody();
+ });
+
+ cy.visit('/admin_mcadmin/test-article-slug');
+ });
+
+ it('initializes pausable images for a user with prefers-reduce-motion unset', () => {
+ // Only two images should be pausable
+ cy.findAllByRole('button', { name: 'Pause animation playback' })
+ .as('pauseButtons')
+ .should('have.length', 2);
+
+ // By default, both buttons should be in the 'playing' state
+ cy.get('@pauseButtons')
+ .first()
+ .should('have.attr', 'aria-pressed', 'false');
+ cy.get('@pauseButtons')
+ .last()
+ .should('have.attr', 'aria-pressed', 'false');
+
+ // Pressing the button should toggle only the state of the individual image
+ cy.get('@pauseButtons')
+ .first()
+ .click()
+ .should('have.attr', 'aria-pressed', 'true');
+ cy.get('@pauseButtons')
+ .last()
+ .should('have.attr', 'aria-pressed', 'false');
+
+ // It should also toggle back to playing
+ cy.get('@pauseButtons')
+ .first()
+ .click()
+ .should('have.attr', 'aria-pressed', 'false');
+ });
+ });
+
+ describe('user prefers reduced motion', () => {
+ beforeEach(() => {
+ Cypress.on('window:before:load', (window) => {
+ // We make sure when prefers-reduced-motion: no-preference is checked, we return false to mimic a user with reduced motion prefs
+ cy.stub(window, 'matchMedia').returns({ matches: false });
+ window.document.body = generateFakePageBody();
+ });
+
+ cy.visit('/admin_mcadmin/test-article-slug');
+ });
+
+ it('initializes pausable images for a user with prefers-reduce-motion set', () => {
+ // Only two images should be pausable
+ cy.findAllByRole('button', { name: 'Pause animation playback' })
+ .as('pauseButtons')
+ .should('have.length', 2);
+
+ // By default, both buttons should be in the 'paused' state
+ cy.get('@pauseButtons')
+ .first()
+ .should('have.attr', 'aria-pressed', 'true');
+ cy.get('@pauseButtons')
+ .last()
+ .should('have.attr', 'aria-pressed', 'true');
+
+ // Pressing the button should toggle only the state of the individual image
+ cy.get('@pauseButtons')
+ .first()
+ .click()
+ .should('have.attr', 'aria-pressed', 'false');
+ cy.get('@pauseButtons')
+ .last()
+ .should('have.attr', 'aria-pressed', 'true');
+
+ // It should also toggle back to paused
+ cy.get('@pauseButtons')
+ .first()
+ .click()
+ .should('have.attr', 'aria-pressed', 'true');
+ });
+ });
+});
diff --git a/package.json b/package.json
index 88373cda9..de517f5e7 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"homepage": "https://github.com/thepracticaldev/dev.to#readme",
"devDependencies": {
"@babel/eslint-parser": "^7.16.5",
+ "@faker-js/faker": "^5.5.3",
"@knapsack-pro/cypress": "^4.5.0",
"@storybook/addon-a11y": "^6.4.17",
"@storybook/addon-actions": "^6.4.14",
@@ -79,7 +80,6 @@
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-no-only-tests": "^2.6.0",
"eslint-plugin-react": "^7.28.0",
- "@faker-js/faker": "^5.5.3",
"husky": "^7.0.4",
"jest": "27.4.7",
"jest-axe": "^4.1.0",
@@ -101,9 +101,9 @@
"@babel/core": "^7.16.7",
"@babel/plugin-proposal-class-properties": "^7.16.0",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
- "@etchteam/storybook-addon-css-variables-theme": "^1.1.0",
"@babel/plugin-transform-react-jsx": "^7.16.7",
"@babel/preset-env": "^7.16.8",
+ "@etchteam/storybook-addon-css-variables-theme": "^1.1.0",
"@github/clipboard-copy-element": "^1.1.2",
"@github/time-elements": "3.1.2",
"@honeybadger-io/js": "^3.2.7",
@@ -128,6 +128,7 @@
"file-loader": "^6.2.0",
"focus-trap": "^6.7.2",
"focus-visible": "^5.2.0",
+ "freezeframe": "^5.0.2",
"he": "^1.2.0",
"i18n-js": "^3.8.0",
"intersection-observer": "^0.12.0",
diff --git a/public/media/test-image.gif b/public/media/test-image.gif
new file mode 100644
index 000000000..384461f8a
Binary files /dev/null and b/public/media/test-image.gif differ
diff --git a/yarn.lock b/yarn.lock
index 423a50d0e..5ab998c58 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9074,6 +9074,11 @@ fragment-cache@^0.2.1:
dependencies:
map-cache "^0.2.2"
+freezeframe@^5.0.2:
+ version "5.0.2"
+ resolved "https://registry.yarnpkg.com/freezeframe/-/freezeframe-5.0.2.tgz#dca477f1836c3932053719a66244244d46b14348"
+ integrity sha512-Lp1ltC2vJGBqxdW5U4Hx+JMW+s9KfTPpxKhSTlpmQxYX5oK66y3GIZo56Jie4XtSOApSbfc8SBcsbZzeTNKfFg==
+
fresh@0.5.2:
version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"