Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

AccessKit Disable GIFs: Optionally delay GIF downloading until hover #1728

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"build": "web-ext build"
},
"devDependencies": {
"@types/dom-webcodecs": "^0.1.13",
"chrome-webstore-upload-cli": "^3.3.1",
"eslint": "^8.57.1",
"eslint-config-semistandard": "^17.0.0",
Expand Down
9 changes: 9 additions & 0 deletions src/features/accesskit.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@
"label": "Pause GIFs until they are hovered over",
"default": false
},
"disable_gifs_loading_mode": {
"type": "select",
"label": "Download paused GIFs:",
"options": [
{ "value": "eager", "label": "immediately" },
{ "value": "lazy", "label": "when hovered" }
],
"default": "eager"
},
"boring_tag_chiclets": {
"type": "checkbox",
"label": "De-animate the Changes/Shop/etc. links carousel",
Expand Down
182 changes: 126 additions & 56 deletions src/features/accesskit/disable_gifs.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,19 @@ import { pageModifications } from '../../utils/mutations.js';
import { keyToCss } from '../../utils/css_map.js';
import { dom } from '../../utils/dom.js';
import { buildStyle, postSelector } from '../../utils/interface.js';
import { memoize } from '../../utils/memoize.js';
import { getPreferences } from '../../utils/preferences.js';

const canvasClass = 'xkit-paused-gif-placeholder';
const posterAttribute = 'data-paused-gif-placeholder';
const pausedContentVar = '--xkit-paused-gif-content';
const pausedBackgroundImageVar = '--xkit-paused-gif-background-image';
const hoverContainerAttribute = 'data-paused-gif-hover-container';
const labelClass = 'xkit-paused-gif-label';
const containerClass = 'xkit-paused-gif-container';
const backgroundGifClass = 'xkit-paused-background-gif';

let loadingMode;

const hovered = `:is(:hover > *, [${hoverContainerAttribute}]:hover *)`;

export const styleElement = buildStyle(`
.${labelClass} {
Expand All @@ -24,90 +32,123 @@ export const styleElement = buildStyle(`
font-weight: bold;
line-height: 1em;
}

.${labelClass}::before {
content: "GIF";
}

.${labelClass}.mini {
font-size: 0.6rem;
}

.${canvasClass} {
position: absolute;
visibility: visible;

background-color: rgb(var(--white));
.${labelClass}${hovered},
img:is([${posterAttribute}], [style*="${pausedContentVar}"]):not(${hovered}) ~ div > ${keyToCss('knightRiderLoader')} {
display: none;
}

*:hover > .${canvasClass},
*:hover > .${labelClass},
.${containerClass}:hover .${canvasClass},
.${containerClass}:hover .${labelClass} {
${keyToCss('background')} > .${labelClass} {
/* prevent double labels in recommended post cards */
display: none;
}

.${backgroundGifClass}:not(:hover) {
background-image: none !important;
background-color: rgb(var(--secondary-accent));
[${posterAttribute}]:not(${hovered}) {
visibility: visible !important;
}
img:has(~ [${posterAttribute}]:not(${hovered})) {
visibility: hidden !important;
}
img:has(~ [${posterAttribute}="lazy"]:not(${hovered})) {
display: none;
}

.${backgroundGifClass}:not(:hover) > div {
color: rgb(var(--black));
img[style*="${pausedContentVar}"]:not(${hovered}) {
content: var(${pausedContentVar});
}
[style*="${pausedBackgroundImageVar}"]:not(${hovered}) {
background-image: var(${pausedBackgroundImageVar}) !important;
}
`);

const addLabel = (element, inside = false) => {
if (element.parentNode.querySelector(`.${labelClass}`) === null) {
const target = inside ? element : element.parentElement;
if (target) {
[...target.querySelectorAll(`.${labelClass}`)].forEach(existingLabel => existingLabel.remove());

const gifLabel = document.createElement('p');
gifLabel.className = element.clientWidth && element.clientWidth < 150
gifLabel.className = target.clientWidth && target.clientWidth < 150
? `${labelClass} mini`
: labelClass;

inside ? element.append(gifLabel) : element.parentNode.append(gifLabel);
target.append(gifLabel);
}
};

const pauseGif = function (gifElement) {
const image = new Image();
image.src = gifElement.currentSrc;
image.onload = () => {
if (gifElement.parentNode && gifElement.parentNode.querySelector(`.${canvasClass}`) === null) {
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
canvas.className = gifElement.className;
canvas.classList.add(canvasClass);
canvas.getContext('2d').drawImage(image, 0, 0);
gifElement.parentNode.append(canvas);
addLabel(gifElement);
const createPausedUrl = memoize(async sourceUrl => {
const response = await fetch(sourceUrl, { headers: { Accept: 'image/webp,*/*' } });
const contentType = response.headers.get('Content-Type');
const canvas = document.createElement('canvas');

/* globals ImageDecoder */
if (typeof ImageDecoder === 'function' && await ImageDecoder.isTypeSupported(contentType)) {
const decoder = new ImageDecoder({
type: contentType,
data: response.body,
preferAnimation: true
});
const { image: videoFrame } = await decoder.decode();
if (decoder.tracks.selectedTrack.animated === false) {
// source image is not animated; decline to pause it
return undefined;
}
};
};
canvas.width = videoFrame.displayWidth;
canvas.height = videoFrame.displayHeight;
canvas.getContext('2d').drawImage(videoFrame, 0, 0);
} else {
if (sourceUrl.endsWith('.webp')) {
// source image may not be animated; decline to pause it
return undefined;
}
const imageBitmap = await response.blob().then(blob => window.createImageBitmap(blob));
canvas.width = imageBitmap.width;
canvas.height = imageBitmap.height;
canvas.getContext('2d').drawImage(imageBitmap, 0, 0);
}
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/webp', 1));
return URL.createObjectURL(blob);
});

const processGifs = function (gifElements) {
gifElements.forEach(gifElement => {
gifElements.forEach(async gifElement => {
if (gifElement.closest('.block-editor-writing-flow')) return;
const pausedGifElements = [
...gifElement.parentNode.querySelectorAll(`.${canvasClass}`),
...gifElement.parentNode.querySelectorAll(`.${labelClass}`)
];
if (pausedGifElements.length) {
gifElement.after(...pausedGifElements);
return;
}

if (gifElement.complete && gifElement.currentSrc) {
pauseGif(gifElement);
const posterElement = gifElement.parentElement.querySelector(keyToCss('poster'));
if (posterElement) {
posterElement.setAttribute(posterAttribute, loadingMode);
} else {
gifElement.onload = () => pauseGif(gifElement);
const sourceUrl = gifElement.currentSrc ||
await new Promise(resolve => gifElement.addEventListener('load', () => resolve(gifElement.currentSrc), { once: true }));

const pausedUrl = await createPausedUrl(sourceUrl);
if (!pausedUrl) return;

gifElement.style.setProperty(pausedContentVar, `url(${pausedUrl})`);
}
addLabel(gifElement);
gifElement.decoding = 'sync';
});
};

const sourceUrlRegex = /(?<=url\(["'])[^)]*?\.(?:gif|gifv|webp)(?=["']\))/g;
const processBackgroundGifs = function (gifBackgroundElements) {
gifBackgroundElements.forEach(gifBackgroundElement => {
gifBackgroundElement.classList.add(backgroundGifClass);
gifBackgroundElements.forEach(async gifBackgroundElement => {
const sourceValue = gifBackgroundElement.style.backgroundImage;
const sourceUrl = sourceValue.match(sourceUrlRegex)?.[0];
if (!sourceUrl) return;

const pausedUrl = await createPausedUrl(sourceUrl);
if (!pausedUrl) return;

gifBackgroundElement.style.setProperty(
pausedBackgroundImageVar,
sourceValue.replaceAll(sourceUrlRegex, pausedUrl)
);
addLabel(gifBackgroundElement, true);
});
};
Expand All @@ -120,40 +161,69 @@ const processRows = function (rowsElements) {
if (row.previousElementSibling?.classList?.contains(containerClass)) {
row.previousElementSibling.append(row);
} else {
const wrapper = dom('div', { class: containerClass });
const wrapper = dom('div', { class: containerClass, [hoverContainerAttribute]: '' });
row.replaceWith(wrapper);
wrapper.append(row);
}
});
});
};

const processHoverableElements = elements =>
elements.forEach(element => element.setAttribute(hoverContainerAttribute, ''));

const onStorageChanged = async function (changes, areaName) {
if (areaName !== 'local') return;

const { 'accesskit.preferences.disable_gifs_loading_mode': modeChanges } = changes;
if (modeChanges?.oldValue === undefined) return;

loadingMode = modeChanges.newValue;
};

export const main = async function () {
({ disable_gifs_loading_mode: loadingMode } = await getPreferences('accesskit'));

const gifImage = `
:is(figure, ${keyToCss('tagImage', 'takeoverBanner')}) img[srcset*=".gif"]:not(${keyToCss('poster')})
:is(figure, ${keyToCss('tagImage', 'takeoverBanner')}) img:is([srcset*=".gif"], [src*=".gif"], [srcset*=".webp"], [src*=".webp"]):not(${keyToCss('poster')})
`;
pageModifications.register(gifImage, processGifs);

const gifBackgroundImage = `
${keyToCss('communityHeaderImage', 'bannerImage')}[style*=".gif"]
${keyToCss('communityHeaderImage', 'bannerImage')}:is([style*=".gif"], [style*=".webp"])
`;
pageModifications.register(gifBackgroundImage, processBackgroundGifs);

pageModifications.register(
`${keyToCss('listTimelineObject')} ${keyToCss('carouselWrapper')} ${keyToCss('postCard')}`,
processHoverableElements
);

pageModifications.register(
`:is(${postSelector}, ${keyToCss('blockEditorContainer')}) ${keyToCss('rows')}`,
processRows
);

browser.storage.onChanged.addListener(onStorageChanged);
};

export const clean = async function () {
browser.storage.onChanged.removeListener(onStorageChanged);

pageModifications.unregister(processGifs);
pageModifications.unregister(processBackgroundGifs);
pageModifications.unregister(processRows);
pageModifications.unregister(processHoverableElements);

[...document.querySelectorAll(`.${containerClass}`)].forEach(wrapper =>
wrapper.replaceWith(...wrapper.children)
);

$(`.${canvasClass}, .${labelClass}`).remove();
$(`.${backgroundGifClass}`).removeClass(backgroundGifClass);
$(`.${labelClass}`).remove();
$(`[${posterAttribute}]`).removeAttr(posterAttribute);
$(`[${hoverContainerAttribute}]`).removeAttr(hoverContainerAttribute);
[...document.querySelectorAll(`img[style*="${pausedContentVar}"]`)]
.forEach(element => element.style.removeProperty(pausedContentVar));
[...document.querySelectorAll(`[style*="${pausedBackgroundImageVar}"]`)]
.forEach(element => element.style.removeProperty(pausedBackgroundImageVar));
};