Created
July 30, 2025 08:58
-
-
Save cheeaun/0004cc68cab1d92af132eb2752a2f909 to your computer and use it in GitHub Desktop.
img-gif.js — web component to render GIF as static image (`manualplay` attribute)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { decodeFrames } from 'modern-gif'; | |
import workerUrl from 'modern-gif/worker?url'; | |
class ImgGif extends HTMLElement { | |
constructor() { | |
super(); | |
this.frame = null; | |
this.canvas = null; | |
this.ctx = null; | |
this.isPlaying = false; | |
this.attachShadow({ mode: 'open' }); | |
} | |
static get observedAttributes() { | |
return [ | |
'src', | |
'alt', | |
'crossorigin', | |
'width', | |
'height', | |
'referrerpolicy', | |
'loading', | |
'fetchpriority', | |
'manualplay', | |
'staticsrc', | |
]; | |
} | |
async connectedCallback() { | |
this.render(); | |
// Default behavior is auto-play unless manualplay is specified | |
if (!this.hasAttribute('manualplay')) { | |
await this.loadGif(); | |
this.play(); | |
} else { | |
// manualplay: load GIF and show first frame in canvas | |
await this.loadGif(); | |
} | |
} | |
disconnectedCallback() { | |
this.frame = null; | |
} | |
attributeChangedCallback(name, oldValue, newValue) { | |
if (oldValue !== newValue) { | |
if (name === 'src') { | |
if (this.frame) { | |
this.frame = null; | |
} | |
} | |
this.render(); | |
if (name === 'src') { | |
this.loadGif().then(() => { | |
if (!this.hasAttribute('manualplay')) { | |
this.play(); | |
} | |
// If manualplay, loadGif already shows first frame in canvas | |
}); | |
} | |
if (name === 'manualplay') { | |
if (this.hasAttribute('manualplay')) { | |
// Switching to manual mode - reset playing state and show canvas | |
this.isPlaying = false; | |
if (!this.frame) { | |
this.loadGif(); | |
} else { | |
this.stop(); | |
} | |
} else { | |
// Switching to auto-play mode | |
this.play(); | |
} | |
} | |
if (name === 'staticsrc' && this.hasAttribute('manualplay')) { | |
if (this.frame) { | |
this.stop(); | |
} | |
} | |
} | |
} | |
async loadGif() { | |
const src = this.getAttribute('src'); | |
if (!src) return; | |
try { | |
const response = await fetch(src); | |
if (!response.ok) { | |
throw new Error(`Failed to fetch GIF: ${response.status}`); | |
} | |
const buffer = await response.arrayBuffer(); | |
if (this.hasAttribute('manualplay')) { | |
// Only decode first frame for efficiency | |
const options = { workerUrl, range: [0, 0] }; | |
const frames = await decodeFrames(buffer, options); | |
console.log('GIF frame', frames); | |
if (!frames || frames.length === 0) { | |
throw new Error('No frames decoded from GIF'); | |
} | |
this.frame = frames[0]; | |
// Try static image first, fallback to canvas first-frame | |
// Only render if not currently playing | |
if (!this.isPlaying) { | |
await this.renderStaticContent(); | |
} | |
} else { | |
// Default: auto-play using native img element (no decoding needed) | |
this.frame = null; | |
this.renderAsImage(); | |
} | |
} catch (error) { | |
console.error('Failed to load GIF:', error); | |
// If loading fails, fallback to regular img | |
this.renderAsImage(); | |
} | |
} | |
renderFirstFrameCanvas() { | |
if (!this.frame) return; | |
console.log('DEBUG: Rendering first frame canvas'); | |
console.log('Frame dimensions:', this.frame.width, 'x', this.frame.height); | |
console.log('Frame data length:', this.frame.data?.length); | |
console.log( | |
'Expected data length:', | |
this.frame.width * this.frame.height * 4, | |
); | |
this.canvas = document.createElement('canvas'); | |
this.ctx = this.canvas.getContext('2d'); | |
this.canvas.width = this.frame.width; | |
this.canvas.height = this.frame.height; | |
// Apply any width/height attributes (as display size, not canvas size) | |
this.applyDimensionAttributes(this.canvas); | |
this.shadowRoot.innerHTML = ''; | |
this.shadowRoot.appendChild(this.canvas); | |
this.drawFirstFrame(); | |
} | |
applyDimensionAttributes(element) { | |
if (this.hasAttribute('width')) { | |
const width = parseInt(this.getAttribute('width'), 10); | |
if (!isNaN(width)) { | |
element.style.width = width + 'px'; | |
} | |
} | |
if (this.hasAttribute('height')) { | |
const height = parseInt(this.getAttribute('height'), 10); | |
if (!isNaN(height)) { | |
element.style.height = height + 'px'; | |
} | |
} | |
} | |
async tryLoadStaticImage() { | |
const staticSrc = this.getAttribute('staticsrc'); | |
if (!staticSrc) { | |
return false; | |
} | |
return new Promise((resolve) => { | |
const img = document.createElement('img'); | |
for (let attr of this.attributes) { | |
if ( | |
attr.name !== 'manualplay' && | |
attr.name !== 'staticsrc' && | |
attr.name !== 'src' | |
) { | |
img.setAttribute(attr.name, attr.value); | |
} | |
} | |
img.src = staticSrc; | |
this.applyDimensionAttributes(img); | |
img.onload = () => { | |
this.shadowRoot.innerHTML = ''; | |
this.shadowRoot.appendChild(img); | |
resolve(true); | |
}; | |
img.onerror = () => { | |
// Failed to load static image, fallback needed | |
console.warn('Failed to load static image:', staticSrc); | |
resolve(false); | |
}; | |
}); | |
} | |
async renderStaticContent() { | |
// Try to load static image first, fallback to canvas first-frame if it fails | |
console.log('DEBUG: renderStaticContent() called'); | |
const staticImageLoaded = await this.tryLoadStaticImage(); | |
if (!staticImageLoaded) { | |
console.log( | |
'DEBUG: static image failed, calling renderFirstFrameCanvas()', | |
); | |
this.renderFirstFrameCanvas(); | |
} else { | |
console.log('DEBUG: static image loaded successfully'); | |
} | |
} | |
drawFirstFrame() { | |
if (!this.frame || !this.ctx) return; | |
console.log('DEBUG: Drawing first frame'); | |
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); | |
try { | |
// Check if frame data is valid | |
if (!this.frame.data || this.frame.data.length === 0) { | |
console.error('Frame data is empty or missing'); | |
return; | |
} | |
// Sample some pixel values to check data | |
console.log( | |
'First 16 bytes of frame data:', | |
Array.from(this.frame.data.slice(0, 16)), | |
); | |
const imageData = new ImageData( | |
this.frame.data, | |
this.frame.width, | |
this.frame.height, | |
); | |
this.ctx.putImageData(imageData, 0, 0); | |
console.log('DEBUG: Successfully drew frame to canvas'); | |
} catch (error) { | |
console.error('Error drawing first frame:', error); | |
// Fallback: fill canvas with a solid color to indicate error | |
this.ctx.fillStyle = '#ff0000'; | |
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); | |
} | |
} | |
play() { | |
// Switch from canvas to img element for natural GIF animation | |
this.isPlaying = true; | |
this.renderAsImage(); | |
} | |
async stop() { | |
// Switch from img back to static content (staticSrc or first-frame canvas) | |
this.isPlaying = false; | |
if (this.frame) { | |
await this.renderStaticContent(); | |
} | |
} | |
renderAsImage() { | |
console.log('DEBUG: renderAsImage() called, isPlaying=', this.isPlaying); | |
const img = document.createElement('img'); | |
for (let attr of this.attributes) { | |
if (attr.name !== 'manualplay') { | |
img.setAttribute(attr.name, attr.value); | |
} | |
} | |
// Apply dimension attributes as styles for proper scaling | |
this.applyDimensionAttributes(img); | |
this.shadowRoot.innerHTML = ''; | |
this.shadowRoot.appendChild(img); | |
console.log( | |
'DEBUG: renderAsImage() finished, shadowRoot now has:', | |
this.shadowRoot.children[0]?.tagName, | |
); | |
} | |
render() { | |
const src = this.getAttribute('src'); | |
if (!src) { | |
this.shadowRoot.innerHTML = ''; | |
return; | |
} | |
let isGif = false; | |
try { | |
const url = new URL(src, window.location.href); | |
const pathname = url.pathname; | |
isGif = pathname.toLowerCase().endsWith('.gif'); | |
} catch (e) { | |
// If URL parsing fails, fall back to simple check | |
isGif = src.toLowerCase().endsWith('.gif'); | |
} | |
if (isGif) { | |
// If we already have frame loaded, keep using it | |
if (this.frame) { | |
return; | |
} | |
this.loadGif().catch(() => { | |
// If loading fails, fallback to regular img | |
this.renderAsImage(); | |
}); | |
} else { | |
// Clean up any existing frame | |
if (this.frame) { | |
this.frame = null; | |
} | |
this.renderAsImage(); | |
} | |
} | |
} | |
if (!customElements.get('img-gif')) { | |
customElements.define('img-gif', ImgGif); | |
} | |
export default ImgGif; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment