/** * @copyright Shachaf Ben-Kiki, JC Brand * @description * Started as a fork of Shachaf Ben-Kiki's jsgif library * https://github.com/shachaf/jsgif * @license MIT License */ import Stream from './stream.js'; import { getOpenPromise } from '@converse/openpromise'; import { parseGIF } from './utils.js'; const DELAY_FACTOR = 10; export default class ConverseGif { /** * Creates a new ConverseGif instance * @param { HTMLElement } el * @param { Object } [options] * @param { Number } [options.width] - The width, in pixels, of the canvas * @param { Number } [options.height] - The height, in pixels, of the canvas * @param { Boolean } [options.loop=true] - Setting this to `true` will enable looping of the gif * @param { Boolean } [options.autoplay=true] - Same as the rel:autoplay attribute above, this arg overrides the img tag info. * @param { Number } [options.max_width] - Scale images over max_width down to max_width. Helpful with mobile. * @param { Function } [options.onIterationEnd] - Add a callback for when the gif reaches the end of a single loop (one iteration). The first argument passed will be the gif HTMLElement. * @param { Boolean } [options.show_progress_bar=true] * @param { String } [options.progress_bg_color='rgba(0,0,0,0.4)'] * @param { String } [options.progress_color='rgba(255,0,22,.8)'] * @param { Number } [options.progress_bar_height=5] */ constructor (el, opts) { this.options = Object.assign({ width: null, height: null, autoplay: true, loop: true, show_progress_bar: true, progress_bg_color: 'rgba(0,0,0,0.4)', progress_color: 'rgba(255,0,22,.8)', progress_bar_height: 5 }, opts ); this.el = el; this.gif_el = el.querySelector('img'); this.canvas = el.querySelector('canvas'); this.ctx = this.canvas.getContext('2d'); // It's good practice to pre-render to an offscreen canvas this.offscreenCanvas = document.createElement('canvas'); this.ctx_scaled = false; this.disposal_method = null; this.disposal_restore_from_idx = null; this.frame = null; this.frame_offsets = []; // elements have .x and .y properties this.frames = []; this.last_disposal_method = null; this.last_img = null; this.load_error = null; this.playing = this.options.autoplay; this.transparency = null; this.frame_delay = null; this.frame_idx = 0; this.iteration_count = 0; this.start = null; this.initialize(); } async initialize () { if (this.options.width && this.options.height) { this.setSizes(this.options.width, this.options.height); } const data = await this.fetchGIF(this.gif_el.src); requestAnimationFrame(() => this.startParsing(data)); } initPlayer () { if (this.load_error) return; if (!(this.options.width && this.options.height)) { this.ctx.scale(this.getCanvasScale(), this.getCanvasScale()); } // Show the first frame this.frame_idx = 0; this.putFrame(this.frame_idx); if (this.options.autoplay) { const delay = (this.frames[this.frame_idx]?.delay ?? 0) * DELAY_FACTOR; setTimeout(() => this.play(), delay); } } /** * Gets the index of the frame "up next" * @returns {number} */ getNextFrameNo () { if (this.frames.length === 0) { return 0; } return (this.frame_idx + 1 + this.frames.length) % this.frames.length; } /** * Called once we've looped through all frames in the GIF * @returns { Boolean } - Returns `true` if the GIF is now paused (i.e. further iterations are not desired) */ onIterationEnd () { this.iteration_count++; this.options.onIterationEnd?.(this); if (!this.options.loop) { this.pause(); return true; } return false; } /** * Inner callback for the `requestAnimationFrame` function. * * This method gets wrapped by an arrow function so that the `previous_timestamp` and * `frame_delay` parameters can also be passed in. The `timestamp` * parameter comes from `requestAnimationFrame`. * * The purpose of this method is to call `putFrame` with the right delay * in order to render the GIF animation. * * Note, this method will cause the *next* upcoming frame to be rendered, * not the current one. * * This means `this.frame_idx` will be incremented before calling `this.putFrame`, so * `putFrame(0)` needs to be called *before* this method, otherwise the * animation will incorrectly start from frame #1 (this is done in `initPlayer`). * * @param { DOMHighRestTimestamp } timestamp - The timestamp as returned by `requestAnimationFrame` * @param { DOMHighRestTimestamp } previous_timestamp - The timestamp from the previous iteration of this method. * We need this in order to calculate whether we have waited long enough to * show the next frame. * @param { Number } frame_delay - The delay (in 1/100th of a second) * before the currently being shown frame should be replaced by a new one. */ onAnimationFrame (timestamp, previous_timestamp, frame_delay) { if (!this.playing) { return; } if ((timestamp - previous_timestamp) < frame_delay) { this.hovering ? this.drawPauseIcon() : this.putFrame(this.frame_idx); // We need to wait longer requestAnimationFrame(ts => this.onAnimationFrame(ts, previous_timestamp, frame_delay)); return; } const next_frame = this.getNextFrameNo(); if (next_frame === 0 && this.onIterationEnd()) { return; } this.frame_idx = next_frame; this.putFrame(this.frame_idx); const delay = (this.frames[this.frame_idx]?.delay || 8) * DELAY_FACTOR; requestAnimationFrame(ts => this.onAnimationFrame(ts, timestamp, delay)); } setSizes (w, h) { this.canvas.width = w * this.getCanvasScale(); this.canvas.height = h * this.getCanvasScale(); this.offscreenCanvas.width = w; this.offscreenCanvas.height = h; this.offscreenCanvas.style.width = w + 'px'; this.offscreenCanvas.style.height = h + 'px'; this.offscreenCanvas.getContext('2d').setTransform(1, 0, 0, 1, 0, 0); } setFrameOffset (frame, offset) { if (!this.frame_offsets[frame]) { this.frame_offsets[frame] = offset; return; } if (typeof offset.x !== 'undefined') { this.frame_offsets[frame].x = offset.x; } if (typeof offset.y !== 'undefined') { this.frame_offsets[frame].y = offset.y; } } doShowProgress (pos, length, draw) { if (draw && this.options.show_progress_bar) { let height = this.options.progress_bar_height; const top = (this.canvas.height - height) / (this.ctx_scaled ? this.getCanvasScale() : 1); const mid = ((pos / length) * this.canvas.width) / (this.ctx_scaled ? this.getCanvasScale() : 1); const width = this.canvas.width / (this.ctx_scaled ? this.getCanvasScale() : 1); height /= this.ctx_scaled ? this.getCanvasScale() : 1; this.ctx.fillStyle = this.options.progress_bg_color; this.ctx.fillRect(mid, top, width - mid, height); this.ctx.fillStyle = this.options.progress_color; this.ctx.fillRect(0, top, mid, height); } } /** * Starts parsing the GIF stream data by calling `parseGIF` and passing in * a map of handler functions. * @param { String } data - The GIF file data, as returned by the server */ startParsing (data) { const stream = new Stream(data); /** * @typedef { Object } GIFParserHandlers * A map of callback functions passed `parseGIF`. These functions are * called as various parts of the GIF file format are parsed. * @property { Function } hdr - Callback to handle the GIF header data * @property { Function } gce - Callback to handle the GIF Graphic Control Extension data * @property { Function } com - Callback to handle the comment extension block * @property { Function } img - Callback to handle image data * @property { Function } eof - Callback once the end of file has been reached */ const handler = { 'hdr': this.withProgress(stream, header => this.handleHeader(header)), 'gce': this.withProgress(stream, gce => this.handleGCE(gce)), 'com': this.withProgress(stream, ), 'img': this.withProgress(stream, img => this.doImg(img), true), 'eof': () => this.handleEOF(stream) }; try { parseGIF(stream, handler); } catch (err) { this.showError('parse'); } } drawError () { this.ctx.fillStyle = 'black'; this.ctx.fillRect( 0, 0, this.options.width ? this.options.width : this.hdr.width, this.options.height ? this.options.height : this.hdr.height ); this.ctx.strokeStyle = 'red'; this.ctx.lineWidth = 3; this.ctx.moveTo(0, 0); this.ctx.lineTo( this.options.width ? this.options.width : this.hdr.width, this.options.height ? this.options.height : this.hdr.height ); this.ctx.moveTo(0, this.options.height ? this.options.height : this.hdr.height); this.ctx.lineTo(this.options.width ? this.options.width : this.hdr.width, 0); this.ctx.stroke(); } showError (errtype) { this.load_error = errtype; this.hdr = { width: this.gif_el.width, height: this.gif_el.height, }; // Fake header. this.frames = []; this.drawError(); this.el.requestUpdate(); } handleHeader (header) { this.hdr = header; this.setSizes( this.options.width ?? this.hdr.width, this.options.height ?? this.hdr.height ); } /** * Handler for GIF Graphic Control Extension (GCE) data */ handleGCE (gce) { this.pushFrame(); this.clear(); this.frame_delay = gce.delayTime; this.transparency = gce.transparencyGiven ? gce.transparencyIndex : null; this.disposal_method = gce.disposalMethod; } /** * Handler for when the end of the GIF's file has been reached */ handleEOF (stream) { this.pushFrame(); this.doDecodeProgress(stream, false); this.initPlayer(); !this.options.autoplay && this.drawPlayIcon(); } pushFrame () { if (!this.frame) return; this.frames.push({ data: this.frame.getImageData(0, 0, this.hdr.width, this.hdr.height), delay: this.frame_delay }); this.frame_offsets.push({ x: 0, y: 0 }); } doImg (img) { this.frame = this.frame || this.offscreenCanvas.getContext('2d'); const currIdx = this.frames.length; //ct = color table, gct = global color table const ct = img.lctFlag ? img.lct : this.hdr.gct; // TODO: What if neither exists? /* * Disposal method indicates the way in which the graphic is to * be treated after being displayed. * * Values : 0 - No disposal specified. The decoder is * not required to take any action. * 1 - Do not dispose. The graphic is to be left * in place. * 2 - Restore to background color. The area used by the * graphic must be restored to the background color. * 3 - Restore to previous. The decoder is required to * restore the area overwritten by the graphic with * what was there prior to rendering the graphic. * * Importantly, "previous" means the frame state * after the last disposal of method 0, 1, or 2. */ if (currIdx > 0) { if (this.last_disposal_method === 3) { // Restore to previous // If we disposed every frame including first frame up to this point, then we have // no composited frame to restore to. In this case, restore to background instead. if (this.disposal_restore_from_idx !== null) { this.frame.putImageData(this.frames[this.disposal_restore_from_idx].data, 0, 0); } else { this.frame.clearRect( this.last_img.leftPos, this.last_img.topPos, this.last_img.width, this.last_img.height ); } } else { this.disposal_restore_from_idx = currIdx - 1; } if (this.last_disposal_method === 2) { // Restore to background color // Browser implementations historically restore to transparent; we do the same. // http://www.wizards-toolkit.org/discourse-server/viewtopic.php?f=1&t=21172#p86079 this.frame.clearRect( this.last_img.leftPos, this.last_img.topPos, this.last_img.width, this.last_img.height ); } } // else, Undefined/Do not dispose. // frame contains final pixel data from the last frame; do nothing //Get existing pixels for img region after applying disposal method const imgData = this.frame.getImageData(img.leftPos, img.topPos, img.width, img.height); //apply color table colors img.pixels.forEach((pixel, i) => { // imgData.data === [R,G,B,A,R,G,B,A,...] if (pixel !== this.transparency) { imgData.data[i * 4 + 0] = ct[pixel][0]; imgData.data[i * 4 + 1] = ct[pixel][1]; imgData.data[i * 4 + 2] = ct[pixel][2]; imgData.data[i * 4 + 3] = 255; // Opaque. } }); this.frame.putImageData(imgData, img.leftPos, img.topPos); if (!this.ctx_scaled) { this.ctx.scale(this.getCanvasScale(), this.getCanvasScale()); this.ctx_scaled = true; } if (!this.last_img) { // This is the first received image, so we draw it this.ctx.drawImage(this.offscreenCanvas, 0, 0); } this.last_img = img; } /** * Draws a gif frame at a specific index inside the canvas. * @param { Number } i - The frame index */ putFrame (i, show_pause_on_hover=true) { if (!this.frames.length) return i = parseInt(i, 10); if (i > this.frames.length - 1 || i < 0) { i = 0; } const offset = this.frame_offsets[i]; this.offscreenCanvas.getContext('2d').putImageData(this.frames[i].data, offset.x, offset.y); this.ctx.globalCompositeOperation = 'copy'; this.ctx.drawImage(this.offscreenCanvas, 0, 0); if (show_pause_on_hover && this.hovering) { this.drawPauseIcon(); } } clear () { this.transparency = null; this.last_disposal_method = this.disposal_method; this.disposal_method = null; this.frame = null; } /** * Start playing the gif */ play () { this.playing = true; requestAnimationFrame(ts => this.onAnimationFrame(ts, 0, 0)); } /** * Pause the gif */ pause () { this.playing = false; requestAnimationFrame(() => this.drawPlayIcon()) } drawPauseIcon () { if (!this.playing) { return; } // Clear the potential play button by re-rendering the current frame this.putFrame(this.frame_idx, false); this.ctx.globalCompositeOperation = 'source-over'; // Draw dark overlay this.ctx.fillStyle = 'rgb(0, 0, 0, 0.25)'; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); const icon_size = this.canvas.height*0.1; // Draw bars this.ctx.lineWidth = this.canvas.height*0.04; this.ctx.beginPath(); this.ctx.moveTo(this.canvas.width/2-icon_size/2, this.canvas.height/2-icon_size); this.ctx.lineTo(this.canvas.width/2-icon_size/2, this.canvas.height/2+icon_size); this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)'; this.ctx.stroke(); this.ctx.beginPath(); this.ctx.moveTo(this.canvas.width/2+icon_size/2, this.canvas.height/2-icon_size); this.ctx.lineTo(this.canvas.width/2+icon_size/2, this.canvas.height/2+icon_size); this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)'; this.ctx.stroke(); // Draw circle this.ctx.lineWidth = this.canvas.height*0.02; this.ctx.strokeStyle = 'rgb(200, 200, 200, 0.75)'; this.ctx.beginPath(); this.ctx.arc( this.canvas.width/2, this.canvas.height/2, icon_size*1.5, 0, 2*Math.PI ); this.ctx.stroke(); } drawPlayIcon () { if (this.playing) { return; } // Clear the potential pause button by re-rendering the current frame this.putFrame(this.frame_idx, false); this.ctx.globalCompositeOperation = 'source-over'; // Draw dark overlay this.ctx.fillStyle = 'rgb(0, 0, 0, 0.25)'; this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); // Draw triangle const triangle_size = this.canvas.height*0.1; const region = new Path2D(); region.moveTo(this.canvas.width/2+triangle_size, this.canvas.height/2); // start at the pointy end region.lineTo(this.canvas.width/2-triangle_size/2, this.canvas.height/2+triangle_size); region.lineTo(this.canvas.width/2-triangle_size/2, this.canvas.height/2-triangle_size); region.closePath(); this.ctx.fillStyle = 'rgb(200, 200, 200, 0.75)'; this.ctx.fill(region); // Draw circle const circle_size = triangle_size*1.5; this.ctx.lineWidth = this.canvas.height*0.02; this.ctx.strokeStyle = 'rgb(200, 200, 200, 0.75)'; this.ctx.beginPath(); this.ctx.arc( this.canvas.width/2, this.canvas.height/2, circle_size, 0, 2*Math.PI ); this.ctx.stroke(); } doDecodeProgress (stream, draw) { this.doShowProgress(stream.pos, stream.data.length, draw); } /** * @param{boolean=} draw Whether to draw progress bar or not; * this is not idempotent because of translucency. * Note that this means that the text will be unsynchronized * with the progress bar on non-frames; * but those are typically so small (GCE etc.) that it doesn't really matter */ withProgress (stream, fn, draw) { return block => { fn?.(block); this.doDecodeProgress(stream, draw); }; } getCanvasScale () { let scale; if (this.options.max_width && this.hdr && this.hdr.width > this.options.max_width) { scale = this.options.max_width / this.hdr.width; } else { scale = 1; } return scale; } /** * Makes an HTTP request to fetch a GIF * @param { String } url * @returns { Promise } Returns a promise which resolves with the response data. */ fetchGIF (url) { const promise = getOpenPromise(); const h = new XMLHttpRequest(); h.open('GET', url, true); h?.overrideMimeType('text/plain; charset=x-user-defined'); h.onload = () => { if (h.status != 200) { this.showError('xhr - response'); return promise.reject(); } promise.resolve(h.response); }; h.onprogress = (e) => (e.lengthComputable && this.doShowProgress(e.loaded, e.total, true)); h.onerror = () => this.showError('xhr'); h.send(); return promise; } }