diff --git a/package-lock.json b/package-lock.json index 996c76ff39..9158f7b4c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "webextension-polyfill": "^0.12.0" }, "devDependencies": { + "@types/dom-webcodecs": "^0.1.13", "chrome-webstore-upload-cli": "^3.3.1", "eslint": "^8.57.1", "eslint-config-semistandard": "^17.0.0", @@ -475,6 +476,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dom-webcodecs": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.13.tgz", + "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", diff --git a/package.json b/package.json index 90c0ab6b76..924c28247f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/features/accesskit/disable_gifs.js b/src/features/accesskit/disable_gifs.js index 002603fc2f..22e23a792e 100644 --- a/src/features/accesskit/disable_gifs.js +++ b/src/features/accesskit/disable_gifs.js @@ -2,11 +2,16 @@ 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'; -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'; + +const hovered = `:is(:hover > *, [${hoverContainerAttribute}]:hover *)`; export const styleElement = buildStyle(` .${labelClass} { @@ -24,90 +29,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; } -.${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)); + const url = URL.createObjectURL(blob); + await dom('img', { src: url }).decode(); + return url; +}); 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); + gifElement.decoding = 'sync'; + + const posterElement = gifElement.parentElement.querySelector(keyToCss('poster')); + if (posterElement) { + posterElement.setAttribute(posterAttribute, ''); } 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); }); }; +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); }); }; @@ -120,7 +158,7 @@ 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); } @@ -128,17 +166,25 @@ const processRows = function (rowsElements) { }); }; +const processHoverableElements = elements => + elements.forEach(element => element.setAttribute(hoverContainerAttribute, '')); + export const main = async function () { 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 @@ -149,11 +195,17 @@ export const clean = async function () { 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)); };