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:
parent
c1a60539b9
commit
1b98b03bde
11 changed files with 298 additions and 4 deletions
3
app/assets/images/pause.svg
Normal file
3
app/assets/images/pause.svg
Normal 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 |
3
app/assets/images/play.svg
Normal file
3
app/assets/images/play.svg
Normal 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 |
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 **********************************
|
||||||
***********************************************/
|
***********************************************/
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
91
app/javascript/utilities/animatedImageUtils.jsx
Normal file
91
app/javascript/utilities/animatedImageUtils.jsx
Normal 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'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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
BIN
public/media/test-image.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 87 KiB |
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue