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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@
|
|||
|
||||
--tooltip-bg: rgba(var(--grey-900), 0.9);
|
||||
--tooltip-color: rgb(var(--white));
|
||||
|
||||
|
||||
/***********************************************
|
||||
** Indicators **********************************
|
||||
***********************************************/
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
|
|
|||
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",
|
||||
"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",
|
||||
|
|
|
|||
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:
|
||||
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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue