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"