Allow users to pause gifs in a post (#16314)

* working POC

* refactors and styling

* add some comments

* css tweaks to keep sizes in step

* add cypress test

* comment added to css
This commit is contained in:
Suzanne Aitchison 2022-02-02 13:45:55 +00:00 committed by GitHub
parent c1a60539b9
commit 1b98b03bde
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 298 additions and 4 deletions

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10ZM9 9v6h2V9H9Zm4 0v6h2V9h-2Z"/>
</svg>

After

Width:  |  Height:  |  Size: 208 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10ZM10.622 8.415a.4.4 0 0 0-.622.332v6.506a.4.4 0 0 0 .622.332l4.879-3.252a.4.4 0 0 0 0-.666l-4.88-3.252h.001Z"/>
</svg>

After

Width:  |  Height:  |  Size: 288 B

View file

@ -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;
}
}
}

View file

@ -160,7 +160,9 @@ sup {
animation-iteration-count: 1 !important; animation-iteration-count: 1 !important;
background-attachment: initial !important; background-attachment: initial !important;
scroll-behavior: auto !important; scroll-behavior: auto !important;
transition-duration: 0s !important;
transition-delay: 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;
} }
} }

View file

@ -251,7 +251,7 @@
--tooltip-bg: rgba(var(--grey-900), 0.9); --tooltip-bg: rgba(var(--grey-900), 0.9);
--tooltip-color: rgb(var(--white)); --tooltip-color: rgb(var(--white));
/*********************************************** /***********************************************
** Indicators ********************************** ** Indicators **********************************
***********************************************/ ***********************************************/

View file

@ -6,6 +6,15 @@ import { embedGists } from '../utilities/gist';
/* global Runtime */ /* global Runtime */
const animatedImages = document.querySelectorAll('[data-animated="true"]');
if (animatedImages.length > 0) {
import('@utilities/animatedImageUtils').then(
({ initializePausableAnimatedImages }) => {
initializePausableAnimatedImages(animatedImages);
},
);
}
const fullscreenActionElements = document.getElementsByClassName( const fullscreenActionElements = document.getElementsByClassName(
'js-fullscreen-code-action', 'js-fullscreen-code-action',
); );

View file

@ -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(
<Button
aria-label="Pause animation playback"
aria-pressed={!okWithMotion}
className="gif-button fs-s gap-2"
onClick={() => ff.toggle()}
>
<Icon src={Play} className="gif-play" />
<Icon src={Pause} className="gif-pause" />
GIF
</Button>,
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'],
});
}
};

View file

@ -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');
});
});
});

View file

@ -40,6 +40,7 @@
"homepage": "https://github.com/thepracticaldev/dev.to#readme", "homepage": "https://github.com/thepracticaldev/dev.to#readme",
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "^7.16.5", "@babel/eslint-parser": "^7.16.5",
"@faker-js/faker": "^5.5.3",
"@knapsack-pro/cypress": "^4.5.0", "@knapsack-pro/cypress": "^4.5.0",
"@storybook/addon-a11y": "^6.4.17", "@storybook/addon-a11y": "^6.4.17",
"@storybook/addon-actions": "^6.4.14", "@storybook/addon-actions": "^6.4.14",
@ -79,7 +80,6 @@
"eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-no-only-tests": "^2.6.0", "eslint-plugin-no-only-tests": "^2.6.0",
"eslint-plugin-react": "^7.28.0", "eslint-plugin-react": "^7.28.0",
"@faker-js/faker": "^5.5.3",
"husky": "^7.0.4", "husky": "^7.0.4",
"jest": "27.4.7", "jest": "27.4.7",
"jest-axe": "^4.1.0", "jest-axe": "^4.1.0",
@ -101,9 +101,9 @@
"@babel/core": "^7.16.7", "@babel/core": "^7.16.7",
"@babel/plugin-proposal-class-properties": "^7.16.0", "@babel/plugin-proposal-class-properties": "^7.16.0",
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "@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/plugin-transform-react-jsx": "^7.16.7",
"@babel/preset-env": "^7.16.8", "@babel/preset-env": "^7.16.8",
"@etchteam/storybook-addon-css-variables-theme": "^1.1.0",
"@github/clipboard-copy-element": "^1.1.2", "@github/clipboard-copy-element": "^1.1.2",
"@github/time-elements": "3.1.2", "@github/time-elements": "3.1.2",
"@honeybadger-io/js": "^3.2.7", "@honeybadger-io/js": "^3.2.7",
@ -128,6 +128,7 @@
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"focus-trap": "^6.7.2", "focus-trap": "^6.7.2",
"focus-visible": "^5.2.0", "focus-visible": "^5.2.0",
"freezeframe": "^5.0.2",
"he": "^1.2.0", "he": "^1.2.0",
"i18n-js": "^3.8.0", "i18n-js": "^3.8.0",
"intersection-observer": "^0.12.0", "intersection-observer": "^0.12.0",

BIN
public/media/test-image.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View file

@ -9074,6 +9074,11 @@ fragment-cache@^0.2.1:
dependencies: dependencies:
map-cache "^0.2.2" 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: fresh@0.5.2:
version "0.5.2" version "0.5.2"
resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"