diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js index 9c335d6c..43ecad54 100644 --- a/src/components/still-image/still-image.js +++ b/src/components/still-image/still-image.js @@ -13,6 +13,7 @@ const StillImage = { return { stopGifs: this.$store.getters.mergedConfig.stopGifs || window.matchMedia('(prefers-reduced-motion: reduce)').matches, isAnimated: false, + imageTypeLabel: '' } }, computed: { @@ -39,27 +40,22 @@ const StillImage = { this.imageLoadError && this.imageLoadError() }, detectAnimation (image) { - // If there are no file extensions, the mimetype isn't set, and no mediaproxy is available, we can't figure out - // the mimetype of the image. - const hasFileExtension = this.src.split('/').pop().includes('.') // TODO: Better check? const mediaProxyAvailable = this.$store.state.instance.mediaProxyAvailable - if (!hasFileExtension && this.mimetype === undefined && !mediaProxyAvailable) { + + // harmless CORS errors without-- clean console with + if (!mediaProxyAvailable) { // It's a bit aggressive to assume all images we can't find the mimetype of is animated, but necessary for // people in need of reduced motion accessibility. As such, we'll consider those images animated if the user // agent is set to prefer reduced motion. Otherwise, it'll just be used as an early exit. - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + // Only for no media-proxy for now, since we're capturing gif, webp, and apng (are there others?) + // Since the canvas and images are not pixel-perfect matching (due to scaling), + // It makes the images jiggle on hover, which is not ideal for accessibility, methinks this.isAnimated = true + } return } - - if (this.mimetype === 'image/gif' || this.src.endsWith('.gif')) { - this.isAnimated = true - return - } - // harmless CORS errors without-- clean console with - if (!mediaProxyAvailable) return - // Animated JPEGs? - if (!(this.src.endsWith('.webp') || this.src.endsWith('.png'))) return + // Browser Cache should ensure image doesn't get loaded twice if cache exists fetch(image.src, { referrerPolicy: 'same-origin' @@ -68,12 +64,20 @@ const StillImage = { // We don't need to read the whole file so only call it once data.body.getReader().read() .then(reader => { - if (this.src.endsWith('.webp') && this.isAnimatedWEBP(reader.value)) { + // Ordered from least to most intensive + if (this.isGIF(reader.value)) { this.isAnimated = true + this.setLabel('GIF') return } - if (this.src.endsWith('.png') && this.isAnimatedPNG(reader.value)) { + if (this.isAnimatedWEBP(reader.value)) { this.isAnimated = true + this.setLabel('WEBP') + return + } + if (this.isAnimatedPNG(reader.value)) { + this.isAnimated = true + this.setLabel('APNG') } }) }) @@ -81,6 +85,23 @@ const StillImage = { // this.imageLoadError && this.imageLoadError() }) }, + setLabel (name) { + this.imageTypeLabel = name; + }, + isGIF (data) { + // I am a perfectly sane individual + // + // GIF HEADER CHUNK + // === START HEADER === + // 47 49 46 38 ("GIF8") + const gifHeader = [0x47, 0x49, 0x46]; + for (let i = 0; i < 3; i++) { + if (data[i] !== gifHeader[i]) { + return false; + } + } + return true + }, isAnimatedWEBP (data) { /** * WEBP HEADER CHUNK @@ -114,16 +135,55 @@ const StillImage = { const idatPos = str.indexOf('IDAT') return (str.substring(0, idatPos > 0 ? idatPos : 0).indexOf('acTL') > 0) }, - drawThumbnail () { - const canvas = this.$refs.canvas - if (!this.$refs.canvas) return - const image = this.$refs.src - const width = image.naturalWidth - const height = image.naturalHeight - canvas.width = width - canvas.height = height - canvas.getContext('2d').drawImage(image, 0, 0, width, height) - } + drawThumbnail() { + const canvas = this.$refs.canvas; + if (!canvas) return; + + const context = canvas.getContext('2d'); + const image = this.$refs.src; + const parentElement = canvas.parentElement; + + // Draw the quick, unscaled version first + context.drawImage(image, 0, 0, parentElement.clientWidth, parentElement.clientHeight); + + // Use requestAnimationFrame to schedule the scaling to the next frame + requestAnimationFrame(() => { + // Compute scaling ratio between the natural dimensions of the image and its display dimensions + const scalingRatioWidth = parentElement.clientWidth / image.naturalWidth; + const scalingRatioHeight = parentElement.clientHeight / image.naturalHeight; + + // Adjust for high-DPI displays + const ratio = window.devicePixelRatio || 1; + canvas.width = image.naturalWidth * scalingRatioWidth * ratio; + canvas.height = image.naturalHeight * scalingRatioHeight * ratio; + canvas.style.width = `${parentElement.clientWidth}px`; + canvas.style.height = `${parentElement.clientHeight}px`; + context.scale(ratio, ratio); + + // Maintain the aspect ratio of the image + const imgAspectRatio = image.naturalWidth / image.naturalHeight; + const canvasAspectRatio = parentElement.clientWidth / parentElement.clientHeight; + + let drawWidth, drawHeight; + + if (imgAspectRatio > canvasAspectRatio) { + drawWidth = parentElement.clientWidth; + drawHeight = parentElement.clientWidth / imgAspectRatio; + } else { + drawHeight = parentElement.clientHeight; + drawWidth = parentElement.clientHeight * imgAspectRatio; + } + + context.clearRect(0, 0, canvas.width, canvas.height); // Clear the previous unscaled image + context.imageSmoothingEnabled = true; + context.imageSmoothingQuality = 'high'; + + // Draw the good one for realsies + const dx = (parentElement.clientWidth - drawWidth) / 2; + const dy = (parentElement.clientHeight - drawHeight) / 2; + context.drawImage(image, dx, dy, drawWidth, drawHeight); + }); + } }, updated () { // On computed animated change diff --git a/src/components/still-image/still-image.vue b/src/components/still-image/still-image.vue index fa3edacf..074a8eef 100644 --- a/src/components/still-image/still-image.vue +++ b/src/components/still-image/still-image.vue @@ -1,9 +1,15 @@