├── .gitignore ├── LICENSE ├── README.md ├── index.js ├── lib └── lib-gif.js ├── package.json └── src └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 18 | .grunt 19 | 20 | # node-waf configuration 21 | .lock-wscript 22 | 23 | # Compiled binary addons (http://nodejs.org/api/addons.html) 24 | build/Release 25 | 26 | # Dependency directory 27 | node_modules 28 | 29 | # Optional npm cache directory 30 | .npm 31 | 32 | # Optional REPL history 33 | .node_repl_history 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, Matthew Conlen 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any 4 | purpose with or without fee is hereby granted, provided that the above 5 | copyright notice and this permission notice appear in all copies. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 8 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 9 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 10 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 11 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 12 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 13 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scrolly-gif 2 | Animate a gif as the user scrolls the page 3 | 4 | This is mostly a wrapper around https://github.com/buzzfeed/libgif-js 5 | 6 | Examples: http://mathisonian.github.io/scrolly-gif/ 7 | 8 | ## install 9 | 10 | ``` 11 | npm install scrolly-gif 12 | ``` 13 | 14 | ## usage 15 | 16 | ```js 17 | var scrollyGif = require('scrolly-gif'); 18 | 19 | // simple: 20 | scrollyGif(document.getElementById('my-gif-element')); 21 | 22 | // or with jquery: 23 | $('img.scrolly-gif').each(function() { 24 | scrollyGif(this); 25 | }); 26 | ``` 27 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = require('./src'); 3 | -------------------------------------------------------------------------------- /lib/lib-gif.js: -------------------------------------------------------------------------------- 1 | /* 2 | SuperGif 3 | 4 | Example usage: 5 | 6 | 7 | 8 | 16 | 17 | Image tag attributes: 18 | 19 | rel:animated_src - If this url is specified, it's loaded into the player instead of src. 20 | This allows a preview frame to be shown until animated gif data is streamed into the canvas 21 | 22 | rel:auto_play - Defaults to 1 if not specified. If set to zero, a call to the play() method is needed 23 | 24 | Constructor options args 25 | 26 | gif Required. The DOM element of an img tag. 27 | loop_mode Optional. Setting this to false will force disable looping of the gif. 28 | auto_play Optional. Same as the rel:auto_play attribute above, this arg overrides the img tag info. 29 | max_width Optional. Scale images over max_width down to max_width. Helpful with mobile. 30 | on_end Optional. 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. 31 | loop_delay Optional. The amount of time to pause (in ms) after each single loop (iteration). 32 | draw_while_loading Optional. Determines whether the gif will be drawn to the canvas whilst it is loaded. 33 | show_progress_bar Optional. Only applies when draw_while_loading is set to true. 34 | 35 | Instance methods 36 | 37 | // loading 38 | load( callback ) Loads the gif specified by the src or rel:animated_src sttributie of the img tag into a canvas element and then calls callback if one is passed 39 | load_url( src, callback ) Loads the gif file specified in the src argument into a canvas element and then calls callback if one is passed 40 | 41 | // play controls 42 | play - Start playing the gif 43 | pause - Stop playing the gif 44 | move_to(i) - Move to frame i of the gif 45 | move_relative(i) - Move i frames ahead (or behind if i < 0) 46 | 47 | // getters 48 | get_canvas The canvas element that the gif is playing in. Handy for assigning event handlers to. 49 | get_playing Whether or not the gif is currently playing 50 | get_loading Whether or not the gif has finished loading/parsing 51 | get_auto_play Whether or not the gif is set to play automatically 52 | get_length The number of frames in the gif 53 | get_current_frame The index of the currently displayed frame of the gif 54 | 55 | For additional customization (viewport inside iframe) these params may be passed: 56 | c_w, c_h - width and height of canvas 57 | vp_t, vp_l, vp_ w, vp_h - top, left, width and height of the viewport 58 | 59 | A bonus: few articles to understand what is going on 60 | http://enthusiasms.org/post/16976438906 61 | http://www.matthewflickinger.com/lab/whatsinagif/bits_and_bytes.asp 62 | http://humpy77.deviantart.com/journal/Frame-Delay-Times-for-Animated-GIFs-214150546 63 | 64 | */ 65 | (function (root, factory) { 66 | if (typeof define === 'function' && define.amd) { 67 | define([], factory); 68 | } else if (typeof exports === 'object') { 69 | module.exports = factory(); 70 | } else { 71 | root.SuperGif = factory(); 72 | } 73 | }(this, function () { 74 | // Generic functions 75 | var bitsToNum = function (ba) { 76 | return ba.reduce(function (s, n) { 77 | return s * 2 + n; 78 | }, 0); 79 | }; 80 | 81 | var byteToBitArr = function (bite) { 82 | var a = []; 83 | for (var i = 7; i >= 0; i--) { 84 | a.push( !! (bite & (1 << i))); 85 | } 86 | return a; 87 | }; 88 | 89 | // Stream 90 | /** 91 | * @constructor 92 | */ 93 | // Make compiler happy. 94 | var Stream = function (data) { 95 | this.data = data; 96 | this.len = this.data.length; 97 | this.pos = 0; 98 | 99 | this.readByte = function () { 100 | if (this.pos >= this.data.length) { 101 | throw new Error('Attempted to read past end of stream.'); 102 | } 103 | if (data instanceof Uint8Array) 104 | return data[this.pos++]; 105 | else 106 | return data.charCodeAt(this.pos++) & 0xFF; 107 | }; 108 | 109 | this.readBytes = function (n) { 110 | var bytes = []; 111 | for (var i = 0; i < n; i++) { 112 | bytes.push(this.readByte()); 113 | } 114 | return bytes; 115 | }; 116 | 117 | this.read = function (n) { 118 | var s = ''; 119 | for (var i = 0; i < n; i++) { 120 | s += String.fromCharCode(this.readByte()); 121 | } 122 | return s; 123 | }; 124 | 125 | this.readUnsigned = function () { // Little-endian. 126 | var a = this.readBytes(2); 127 | return (a[1] << 8) + a[0]; 128 | }; 129 | }; 130 | 131 | var lzwDecode = function (minCodeSize, data) { 132 | // TODO: Now that the GIF parser is a bit different, maybe this should get an array of bytes instead of a String? 133 | var pos = 0; // Maybe this streaming thing should be merged with the Stream? 134 | var readCode = function (size) { 135 | var code = 0; 136 | for (var i = 0; i < size; i++) { 137 | if (data.charCodeAt(pos >> 3) & (1 << (pos & 7))) { 138 | code |= 1 << i; 139 | } 140 | pos++; 141 | } 142 | return code; 143 | }; 144 | 145 | var output = []; 146 | 147 | var clearCode = 1 << minCodeSize; 148 | var eoiCode = clearCode + 1; 149 | 150 | var codeSize = minCodeSize + 1; 151 | 152 | var dict = []; 153 | 154 | var clear = function () { 155 | dict = []; 156 | codeSize = minCodeSize + 1; 157 | for (var i = 0; i < clearCode; i++) { 158 | dict[i] = [i]; 159 | } 160 | dict[clearCode] = []; 161 | dict[eoiCode] = null; 162 | 163 | }; 164 | 165 | var code; 166 | var last; 167 | 168 | while (true) { 169 | last = code; 170 | code = readCode(codeSize); 171 | 172 | if (code === clearCode) { 173 | clear(); 174 | continue; 175 | } 176 | if (code === eoiCode) break; 177 | 178 | if (code < dict.length) { 179 | if (last !== clearCode) { 180 | dict.push(dict[last].concat(dict[code][0])); 181 | } 182 | } 183 | else { 184 | if (code !== dict.length) throw new Error('Invalid LZW code.'); 185 | dict.push(dict[last].concat(dict[last][0])); 186 | } 187 | output.push.apply(output, dict[code]); 188 | 189 | if (dict.length === (1 << codeSize) && codeSize < 12) { 190 | // If we're at the last code and codeSize is 12, the next code will be a clearCode, and it'll be 12 bits long. 191 | codeSize++; 192 | } 193 | } 194 | 195 | // I don't know if this is technically an error, but some GIFs do it. 196 | //if (Math.ceil(pos / 8) !== data.length) throw new Error('Extraneous LZW bytes.'); 197 | return output; 198 | }; 199 | 200 | 201 | // The actual parsing; returns an object with properties. 202 | var parseGIF = function (st, handler) { 203 | handler || (handler = {}); 204 | 205 | // LZW (GIF-specific) 206 | var parseCT = function (entries) { // Each entry is 3 bytes, for RGB. 207 | var ct = []; 208 | for (var i = 0; i < entries; i++) { 209 | ct.push(st.readBytes(3)); 210 | } 211 | return ct; 212 | }; 213 | 214 | var readSubBlocks = function () { 215 | var size, data; 216 | data = ''; 217 | do { 218 | size = st.readByte(); 219 | data += st.read(size); 220 | } while (size !== 0); 221 | return data; 222 | }; 223 | 224 | var parseHeader = function () { 225 | var hdr = {}; 226 | hdr.sig = st.read(3); 227 | hdr.ver = st.read(3); 228 | if (hdr.sig !== 'GIF') throw new Error('Not a GIF file.'); // XXX: This should probably be handled more nicely. 229 | hdr.width = st.readUnsigned(); 230 | hdr.height = st.readUnsigned(); 231 | 232 | var bits = byteToBitArr(st.readByte()); 233 | hdr.gctFlag = bits.shift(); 234 | hdr.colorRes = bitsToNum(bits.splice(0, 3)); 235 | hdr.sorted = bits.shift(); 236 | hdr.gctSize = bitsToNum(bits.splice(0, 3)); 237 | 238 | hdr.bgColor = st.readByte(); 239 | hdr.pixelAspectRatio = st.readByte(); // if not 0, aspectRatio = (pixelAspectRatio + 15) / 64 240 | if (hdr.gctFlag) { 241 | hdr.gct = parseCT(1 << (hdr.gctSize + 1)); 242 | } 243 | handler.hdr && handler.hdr(hdr); 244 | }; 245 | 246 | var parseExt = function (block) { 247 | var parseGCExt = function (block) { 248 | var blockSize = st.readByte(); // Always 4 249 | var bits = byteToBitArr(st.readByte()); 250 | block.reserved = bits.splice(0, 3); // Reserved; should be 000. 251 | block.disposalMethod = bitsToNum(bits.splice(0, 3)); 252 | block.userInput = bits.shift(); 253 | block.transparencyGiven = bits.shift(); 254 | 255 | block.delayTime = st.readUnsigned(); 256 | 257 | block.transparencyIndex = st.readByte(); 258 | 259 | block.terminator = st.readByte(); 260 | 261 | handler.gce && handler.gce(block); 262 | }; 263 | 264 | var parseComExt = function (block) { 265 | block.comment = readSubBlocks(); 266 | handler.com && handler.com(block); 267 | }; 268 | 269 | var parsePTExt = function (block) { 270 | // No one *ever* uses this. If you use it, deal with parsing it yourself. 271 | var blockSize = st.readByte(); // Always 12 272 | block.ptHeader = st.readBytes(12); 273 | block.ptData = readSubBlocks(); 274 | handler.pte && handler.pte(block); 275 | }; 276 | 277 | var parseAppExt = function (block) { 278 | var parseNetscapeExt = function (block) { 279 | var blockSize = st.readByte(); // Always 3 280 | block.unknown = st.readByte(); // ??? Always 1? What is this? 281 | block.iterations = st.readUnsigned(); 282 | block.terminator = st.readByte(); 283 | handler.app && handler.app.NETSCAPE && handler.app.NETSCAPE(block); 284 | }; 285 | 286 | var parseUnknownAppExt = function (block) { 287 | block.appData = readSubBlocks(); 288 | // FIXME: This won't work if a handler wants to match on any identifier. 289 | handler.app && handler.app[block.identifier] && handler.app[block.identifier](block); 290 | }; 291 | 292 | var blockSize = st.readByte(); // Always 11 293 | block.identifier = st.read(8); 294 | block.authCode = st.read(3); 295 | switch (block.identifier) { 296 | case 'NETSCAPE': 297 | parseNetscapeExt(block); 298 | break; 299 | default: 300 | parseUnknownAppExt(block); 301 | break; 302 | } 303 | }; 304 | 305 | var parseUnknownExt = function (block) { 306 | block.data = readSubBlocks(); 307 | handler.unknown && handler.unknown(block); 308 | }; 309 | 310 | block.label = st.readByte(); 311 | switch (block.label) { 312 | case 0xF9: 313 | block.extType = 'gce'; 314 | parseGCExt(block); 315 | break; 316 | case 0xFE: 317 | block.extType = 'com'; 318 | parseComExt(block); 319 | break; 320 | case 0x01: 321 | block.extType = 'pte'; 322 | parsePTExt(block); 323 | break; 324 | case 0xFF: 325 | block.extType = 'app'; 326 | parseAppExt(block); 327 | break; 328 | default: 329 | block.extType = 'unknown'; 330 | parseUnknownExt(block); 331 | break; 332 | } 333 | }; 334 | 335 | var parseImg = function (img) { 336 | var deinterlace = function (pixels, width) { 337 | // Of course this defeats the purpose of interlacing. And it's *probably* 338 | // the least efficient way it's ever been implemented. But nevertheless... 339 | var newPixels = new Array(pixels.length); 340 | var rows = pixels.length / width; 341 | var cpRow = function (toRow, fromRow) { 342 | var fromPixels = pixels.slice(fromRow * width, (fromRow + 1) * width); 343 | newPixels.splice.apply(newPixels, [toRow * width, width].concat(fromPixels)); 344 | }; 345 | 346 | // See appendix E. 347 | var offsets = [0, 4, 2, 1]; 348 | var steps = [8, 8, 4, 2]; 349 | 350 | var fromRow = 0; 351 | for (var pass = 0; pass < 4; pass++) { 352 | for (var toRow = offsets[pass]; toRow < rows; toRow += steps[pass]) { 353 | cpRow(toRow, fromRow) 354 | fromRow++; 355 | } 356 | } 357 | 358 | return newPixels; 359 | }; 360 | 361 | img.leftPos = st.readUnsigned(); 362 | img.topPos = st.readUnsigned(); 363 | img.width = st.readUnsigned(); 364 | img.height = st.readUnsigned(); 365 | 366 | var bits = byteToBitArr(st.readByte()); 367 | img.lctFlag = bits.shift(); 368 | img.interlaced = bits.shift(); 369 | img.sorted = bits.shift(); 370 | img.reserved = bits.splice(0, 2); 371 | img.lctSize = bitsToNum(bits.splice(0, 3)); 372 | 373 | if (img.lctFlag) { 374 | img.lct = parseCT(1 << (img.lctSize + 1)); 375 | } 376 | 377 | img.lzwMinCodeSize = st.readByte(); 378 | 379 | var lzwData = readSubBlocks(); 380 | 381 | img.pixels = lzwDecode(img.lzwMinCodeSize, lzwData); 382 | 383 | if (img.interlaced) { // Move 384 | img.pixels = deinterlace(img.pixels, img.width); 385 | } 386 | 387 | handler.img && handler.img(img); 388 | }; 389 | 390 | var parseBlock = function () { 391 | var block = {}; 392 | block.sentinel = st.readByte(); 393 | 394 | switch (String.fromCharCode(block.sentinel)) { // For ease of matching 395 | case '!': 396 | block.type = 'ext'; 397 | parseExt(block); 398 | break; 399 | case ',': 400 | block.type = 'img'; 401 | parseImg(block); 402 | break; 403 | case ';': 404 | block.type = 'eof'; 405 | handler.eof && handler.eof(block); 406 | break; 407 | default: 408 | throw new Error('Unknown block: 0x' + block.sentinel.toString(16)); // TODO: Pad this with a 0. 409 | } 410 | 411 | if (block.type !== 'eof') setTimeout(parseBlock, 0); 412 | }; 413 | 414 | var parse = function () { 415 | parseHeader(); 416 | setTimeout(parseBlock, 0); 417 | }; 418 | 419 | parse(); 420 | }; 421 | 422 | var SuperGif = function ( opts ) { 423 | var options = { 424 | //viewport position 425 | vp_l: 0, 426 | vp_t: 0, 427 | vp_w: null, 428 | vp_h: null, 429 | //canvas sizes 430 | c_w: null, 431 | c_h: null 432 | }; 433 | for (var i in opts ) { options[i] = opts[i] } 434 | if (options.vp_w && options.vp_h) options.is_vp = true; 435 | 436 | var stream; 437 | var hdr; 438 | 439 | var loadError = null; 440 | var loading = false; 441 | 442 | var transparency = null; 443 | var delay = null; 444 | var disposalMethod = null; 445 | var disposalRestoreFromIdx = null; 446 | var lastDisposalMethod = null; 447 | var frame = null; 448 | var lastImg = null; 449 | 450 | var playing = true; 451 | var forward = true; 452 | 453 | var ctx_scaled = false; 454 | 455 | var frames = []; 456 | var frameOffsets = []; // elements have .x and .y properties 457 | 458 | var gif = options.gif; 459 | if (typeof options.auto_play == 'undefined') 460 | options.auto_play = (!gif.getAttribute('rel:auto_play') || gif.getAttribute('rel:auto_play') == '1'); 461 | 462 | var onEndListener = (options.hasOwnProperty('on_end') ? options.on_end : null); 463 | var loopDelay = (options.hasOwnProperty('loop_delay') ? options.loop_delay : 0); 464 | var overrideLoopMode = (options.hasOwnProperty('loop_mode') ? options.loop_mode : 'auto'); 465 | var drawWhileLoading = (options.hasOwnProperty('draw_while_loading') ? options.draw_while_loading : true); 466 | var showProgressBar = drawWhileLoading ? (options.hasOwnProperty('show_progress_bar') ? options.show_progress_bar : true) : false; 467 | var progressBarHeight = (options.hasOwnProperty('progressbar_height') ? options.progressbar_height : 25); 468 | var progressBarBackgroundColor = (options.hasOwnProperty('progressbar_background_color') ? options.progressbar_background_color : 'rgba(255,255,255,0.4)'); 469 | var progressBarForegroundColor = (options.hasOwnProperty('progressbar_foreground_color') ? options.progressbar_foreground_color : 'rgba(255,0,22,.8)'); 470 | 471 | var clear = function () { 472 | transparency = null; 473 | delay = null; 474 | lastDisposalMethod = disposalMethod; 475 | disposalMethod = null; 476 | frame = null; 477 | }; 478 | 479 | // XXX: There's probably a better way to handle catching exceptions when 480 | // callbacks are involved. 481 | var doParse = function () { 482 | try { 483 | parseGIF(stream, handler); 484 | } 485 | catch (err) { 486 | doLoadError('parse'); 487 | } 488 | }; 489 | 490 | var doText = function (text) { 491 | toolbar.innerHTML = text; // innerText? Escaping? Whatever. 492 | toolbar.style.visibility = 'visible'; 493 | }; 494 | 495 | var setSizes = function(w, h) { 496 | canvas.width = w * get_canvas_scale(); 497 | canvas.height = h * get_canvas_scale(); 498 | toolbar.style.minWidth = ( w * get_canvas_scale() ) + 'px'; 499 | 500 | tmpCanvas.width = w; 501 | tmpCanvas.height = h; 502 | tmpCanvas.style.width = w + 'px'; 503 | tmpCanvas.style.height = h + 'px'; 504 | tmpCanvas.getContext('2d').setTransform(1, 0, 0, 1, 0, 0); 505 | }; 506 | 507 | var setFrameOffset = function(frame, offset) { 508 | if (!frameOffsets[frame]) { 509 | frameOffsets[frame] = offset; 510 | return; 511 | } 512 | if (typeof offset.x !== 'undefined') { 513 | frameOffsets[frame].x = offset.x; 514 | } 515 | if (typeof offset.y !== 'undefined') { 516 | frameOffsets[frame].y = offset.y; 517 | } 518 | }; 519 | 520 | var doShowProgress = function (pos, length, draw) { 521 | if (draw && showProgressBar) { 522 | var height = progressBarHeight; 523 | var left, mid, top, width; 524 | if (options.is_vp) { 525 | if (!ctx_scaled) { 526 | top = (options.vp_t + options.vp_h - height); 527 | height = height; 528 | left = options.vp_l; 529 | mid = left + (pos / length) * options.vp_w; 530 | width = canvas.width; 531 | } else { 532 | top = (options.vp_t + options.vp_h - height) / get_canvas_scale(); 533 | height = height / get_canvas_scale(); 534 | left = (options.vp_l / get_canvas_scale() ); 535 | mid = left + (pos / length) * (options.vp_w / get_canvas_scale()); 536 | width = canvas.width / get_canvas_scale(); 537 | } 538 | //some debugging, draw rect around viewport 539 | if (false) { 540 | if (!ctx_scaled) { 541 | var l = options.vp_l, t = options.vp_t; 542 | var w = options.vp_w, h = options.vp_h; 543 | } else { 544 | var l = options.vp_l/get_canvas_scale(), t = options.vp_t/get_canvas_scale(); 545 | var w = options.vp_w/get_canvas_scale(), h = options.vp_h/get_canvas_scale(); 546 | } 547 | ctx.rect(l,t,w,h); 548 | ctx.stroke(); 549 | } 550 | } 551 | else { 552 | top = (canvas.height - height) / (ctx_scaled ? get_canvas_scale() : 1); 553 | mid = ((pos / length) * canvas.width) / (ctx_scaled ? get_canvas_scale() : 1); 554 | width = canvas.width / (ctx_scaled ? get_canvas_scale() : 1 ); 555 | height /= ctx_scaled ? get_canvas_scale() : 1; 556 | } 557 | 558 | ctx.fillStyle = progressBarBackgroundColor; 559 | ctx.fillRect(mid, top, width - mid, height); 560 | 561 | ctx.fillStyle = progressBarForegroundColor; 562 | ctx.fillRect(0, top, mid, height); 563 | } 564 | }; 565 | 566 | var doLoadError = function (originOfError) { 567 | var drawError = function () { 568 | ctx.fillStyle = 'black'; 569 | ctx.fillRect(0, 0, options.c_w ? options.c_w : hdr.width, options.c_h ? options.c_h : hdr.height); 570 | ctx.strokeStyle = 'red'; 571 | ctx.lineWidth = 3; 572 | ctx.moveTo(0, 0); 573 | ctx.lineTo(options.c_w ? options.c_w : hdr.width, options.c_h ? options.c_h : hdr.height); 574 | ctx.moveTo(0, options.c_h ? options.c_h : hdr.height); 575 | ctx.lineTo(options.c_w ? options.c_w : hdr.width, 0); 576 | ctx.stroke(); 577 | }; 578 | 579 | loadError = originOfError; 580 | hdr = { 581 | width: gif.width, 582 | height: gif.height 583 | }; // Fake header. 584 | frames = []; 585 | drawError(); 586 | }; 587 | 588 | var doHdr = function (_hdr) { 589 | hdr = _hdr; 590 | setSizes(hdr.width, hdr.height) 591 | }; 592 | 593 | var doGCE = function (gce) { 594 | pushFrame(); 595 | clear(); 596 | transparency = gce.transparencyGiven ? gce.transparencyIndex : null; 597 | delay = gce.delayTime; 598 | disposalMethod = gce.disposalMethod; 599 | // We don't have much to do with the rest of GCE. 600 | }; 601 | 602 | var pushFrame = function () { 603 | if (!frame) return; 604 | frames.push({ 605 | data: frame.getImageData(0, 0, hdr.width, hdr.height), 606 | delay: delay 607 | }); 608 | frameOffsets.push({ x: 0, y: 0 }); 609 | }; 610 | 611 | var doImg = function (img) { 612 | if (!frame) frame = tmpCanvas.getContext('2d'); 613 | 614 | var currIdx = frames.length; 615 | 616 | //ct = color table, gct = global color table 617 | var ct = img.lctFlag ? img.lct : hdr.gct; // TODO: What if neither exists? 618 | 619 | /* 620 | Disposal method indicates the way in which the graphic is to 621 | be treated after being displayed. 622 | 623 | Values : 0 - No disposal specified. The decoder is 624 | not required to take any action. 625 | 1 - Do not dispose. The graphic is to be left 626 | in place. 627 | 2 - Restore to background color. The area used by the 628 | graphic must be restored to the background color. 629 | 3 - Restore to previous. The decoder is required to 630 | restore the area overwritten by the graphic with 631 | what was there prior to rendering the graphic. 632 | 633 | Importantly, "previous" means the frame state 634 | after the last disposal of method 0, 1, or 2. 635 | */ 636 | if (currIdx > 0) { 637 | if (lastDisposalMethod === 3) { 638 | // Restore to previous 639 | // If we disposed every frame including first frame up to this point, then we have 640 | // no composited frame to restore to. In this case, restore to background instead. 641 | if (disposalRestoreFromIdx !== null) { 642 | frame.putImageData(frames[disposalRestoreFromIdx].data, 0, 0); 643 | } else { 644 | frame.clearRect(lastImg.leftPos, lastImg.topPos, lastImg.width, lastImg.height); 645 | } 646 | } else { 647 | disposalRestoreFromIdx = currIdx - 1; 648 | } 649 | 650 | if (lastDisposalMethod === 2) { 651 | // Restore to background color 652 | // Browser implementations historically restore to transparent; we do the same. 653 | // http://www.wizards-toolkit.org/discourse-server/viewtopic.php?f=1&t=21172#p86079 654 | frame.clearRect(lastImg.leftPos, lastImg.topPos, lastImg.width, lastImg.height); 655 | } 656 | } 657 | // else, Undefined/Do not dispose. 658 | // frame contains final pixel data from the last frame; do nothing 659 | 660 | //Get existing pixels for img region after applying disposal method 661 | var imgData = frame.getImageData(img.leftPos, img.topPos, img.width, img.height); 662 | 663 | //apply color table colors 664 | img.pixels.forEach(function (pixel, i) { 665 | // imgData.data === [R,G,B,A,R,G,B,A,...] 666 | if (pixel !== transparency) { 667 | imgData.data[i * 4 + 0] = ct[pixel][0]; 668 | imgData.data[i * 4 + 1] = ct[pixel][1]; 669 | imgData.data[i * 4 + 2] = ct[pixel][2]; 670 | imgData.data[i * 4 + 3] = 255; // Opaque. 671 | } 672 | }); 673 | 674 | frame.putImageData(imgData, img.leftPos, img.topPos); 675 | 676 | if (!ctx_scaled) { 677 | ctx.scale(get_canvas_scale(),get_canvas_scale()); 678 | ctx_scaled = true; 679 | } 680 | 681 | // We could use the on-page canvas directly, except that we draw a progress 682 | // bar for each image chunk (not just the final image). 683 | if (drawWhileLoading) { 684 | ctx.drawImage(tmpCanvas, 0, 0); 685 | drawWhileLoading = options.auto_play; 686 | } 687 | 688 | lastImg = img; 689 | }; 690 | 691 | var player = (function () { 692 | var i = -1; 693 | var iterationCount = 0; 694 | 695 | var showingInfo = false; 696 | var pinned = false; 697 | 698 | /** 699 | * Gets the index of the frame "up next". 700 | * @returns {number} 701 | */ 702 | var getNextFrameNo = function () { 703 | var delta = (forward ? 1 : -1); 704 | return (i + delta + frames.length) % frames.length; 705 | }; 706 | 707 | var stepFrame = function (amount) { // XXX: Name is confusing. 708 | i = i + amount; 709 | 710 | putFrame(); 711 | }; 712 | 713 | var step = (function () { 714 | var stepping = false; 715 | 716 | var completeLoop = function () { 717 | if (onEndListener !== null) 718 | onEndListener(gif); 719 | iterationCount++; 720 | 721 | if (overrideLoopMode !== false || iterationCount < 0) { 722 | doStep(); 723 | } else { 724 | stepping = false; 725 | playing = false; 726 | } 727 | }; 728 | 729 | var doStep = function () { 730 | stepping = playing; 731 | if (!stepping) return; 732 | 733 | stepFrame(1); 734 | var delay = frames[i].delay * 10; 735 | if (!delay) delay = 100; // FIXME: Should this even default at all? What should it be? 736 | 737 | var nextFrameNo = getNextFrameNo(); 738 | if (nextFrameNo === 0) { 739 | delay += loopDelay; 740 | setTimeout(completeLoop, delay); 741 | } else { 742 | setTimeout(doStep, delay); 743 | } 744 | }; 745 | 746 | return function () { 747 | if (!stepping) setTimeout(doStep, 0); 748 | }; 749 | }()); 750 | 751 | var putFrame = function () { 752 | var offset; 753 | i = parseInt(i, 10); 754 | 755 | if (i > frames.length - 1){ 756 | i = 0; 757 | } 758 | 759 | if (i < 0){ 760 | i = 0; 761 | } 762 | 763 | offset = frameOffsets[i]; 764 | 765 | tmpCanvas.getContext("2d").putImageData(frames[i].data, offset.x, offset.y); 766 | ctx.globalCompositeOperation = "copy"; 767 | ctx.drawImage(tmpCanvas, 0, 0); 768 | }; 769 | 770 | var play = function () { 771 | playing = true; 772 | step(); 773 | }; 774 | 775 | var pause = function () { 776 | playing = false; 777 | }; 778 | 779 | 780 | return { 781 | init: function () { 782 | if (loadError) return; 783 | 784 | if ( ! (options.c_w && options.c_h) ) { 785 | ctx.scale(get_canvas_scale(),get_canvas_scale()); 786 | } 787 | 788 | if (options.auto_play) { 789 | step(); 790 | } 791 | else { 792 | i = 0; 793 | putFrame(); 794 | } 795 | }, 796 | step: step, 797 | play: play, 798 | pause: pause, 799 | playing: playing, 800 | move_relative: stepFrame, 801 | current_frame: function() { return i; }, 802 | length: function() { return frames.length }, 803 | move_to: function ( frame_idx ) { 804 | i = frame_idx; 805 | putFrame(); 806 | } 807 | } 808 | }()); 809 | 810 | var doDecodeProgress = function (draw) { 811 | doShowProgress(stream.pos, stream.data.length, draw); 812 | }; 813 | 814 | var doNothing = function () {}; 815 | /** 816 | * @param{boolean=} draw Whether to draw progress bar or not; this is not idempotent because of translucency. 817 | * Note that this means that the text will be unsynchronized with the progress bar on non-frames; 818 | * but those are typically so small (GCE etc.) that it doesn't really matter. TODO: Do this properly. 819 | */ 820 | var withProgress = function (fn, draw) { 821 | return function (block) { 822 | fn(block); 823 | doDecodeProgress(draw); 824 | }; 825 | }; 826 | 827 | 828 | var handler = { 829 | hdr: withProgress(doHdr), 830 | gce: withProgress(doGCE), 831 | com: withProgress(doNothing), 832 | // I guess that's all for now. 833 | app: { 834 | // TODO: Is there much point in actually supporting iterations? 835 | NETSCAPE: withProgress(doNothing) 836 | }, 837 | img: withProgress(doImg, true), 838 | eof: function (block) { 839 | //toolbar.style.display = ''; 840 | pushFrame(); 841 | doDecodeProgress(false); 842 | if ( ! (options.c_w && options.c_h) ) { 843 | canvas.width = hdr.width * get_canvas_scale(); 844 | canvas.height = hdr.height * get_canvas_scale(); 845 | } 846 | player.init(); 847 | loading = false; 848 | if (load_callback) { 849 | load_callback(gif); 850 | } 851 | 852 | } 853 | }; 854 | 855 | var init = function () { 856 | var parent = gif.parentNode; 857 | 858 | var div = document.createElement('div'); 859 | canvas = document.createElement('canvas'); 860 | ctx = canvas.getContext('2d'); 861 | toolbar = document.createElement('div'); 862 | 863 | tmpCanvas = document.createElement('canvas'); 864 | 865 | div.width = canvas.width = gif.width; 866 | div.height = canvas.height = gif.height; 867 | toolbar.style.minWidth = gif.width + 'px'; 868 | 869 | div.className = 'jsgif'; 870 | toolbar.className = 'jsgif_toolbar'; 871 | div.appendChild(canvas); 872 | div.appendChild(toolbar); 873 | 874 | parent.insertBefore(div, gif); 875 | parent.removeChild(gif); 876 | 877 | if (options.c_w && options.c_h) setSizes(options.c_w, options.c_h); 878 | initialized=true; 879 | }; 880 | 881 | var get_canvas_scale = function() { 882 | var scale; 883 | if (options.max_width && hdr && hdr.width > options.max_width) { 884 | scale = options.max_width / hdr.width; 885 | } 886 | else { 887 | scale = 1; 888 | } 889 | return scale; 890 | } 891 | 892 | var canvas, ctx, toolbar, tmpCanvas; 893 | var initialized = false; 894 | var load_callback = false; 895 | 896 | var load_setup = function(callback) { 897 | if (loading) return false; 898 | if (callback) load_callback = callback; 899 | else load_callback = false; 900 | 901 | loading = true; 902 | frames = []; 903 | clear(); 904 | disposalRestoreFromIdx = null; 905 | lastDisposalMethod = null; 906 | frame = null; 907 | lastImg = null; 908 | 909 | return true; 910 | } 911 | 912 | return { 913 | // play controls 914 | play: player.play, 915 | pause: player.pause, 916 | move_relative: player.move_relative, 917 | move_to: player.move_to, 918 | 919 | // getters for instance vars 920 | get_playing : function() { return playing }, 921 | get_canvas : function() { return canvas }, 922 | get_canvas_scale : function() { return get_canvas_scale() }, 923 | get_loading : function() { return loading }, 924 | get_auto_play : function() { return options.auto_play }, 925 | get_length : function() { return player.length() }, 926 | get_current_frame: function() { return player.current_frame() }, 927 | load_url: function(src,callback){ 928 | if (!load_setup(callback)) return; 929 | 930 | var h = new XMLHttpRequest(); 931 | // new browsers (XMLHttpRequest2-compliant) 932 | h.open('GET', src, true); 933 | 934 | if ('overrideMimeType' in h) { 935 | h.overrideMimeType('text/plain; charset=x-user-defined'); 936 | } 937 | 938 | // old browsers (XMLHttpRequest-compliant) 939 | else if ('responseType' in h) { 940 | h.responseType = 'arraybuffer'; 941 | } 942 | 943 | // IE9 (Microsoft.XMLHTTP-compliant) 944 | else { 945 | h.setRequestHeader('Accept-Charset', 'x-user-defined'); 946 | } 947 | 948 | h.onloadstart = function() { 949 | // Wait until connection is opened to replace the gif element with a canvas to avoid a blank img 950 | if (!initialized) init(); 951 | }; 952 | h.onload = function(e) { 953 | if (this.status != 200) { 954 | doLoadError('xhr - response'); 955 | } 956 | // emulating response field for IE9 957 | if (!('response' in this)) { 958 | this.response = new VBArray(this.responseText).toArray().map(String.fromCharCode).join(''); 959 | } 960 | var data = this.response; 961 | if (data.toString().indexOf("ArrayBuffer") > 0) { 962 | data = new Uint8Array(data); 963 | } 964 | 965 | stream = new Stream(data); 966 | setTimeout(doParse, 0); 967 | }; 968 | h.onprogress = function (e) { 969 | if (e.lengthComputable) doShowProgress(e.loaded, e.total, true); 970 | }; 971 | h.onerror = function() { doLoadError('xhr'); }; 972 | h.send(); 973 | }, 974 | load: function (callback) { 975 | this.load_url(gif.getAttribute('rel:animated_src') || gif.src,callback); 976 | }, 977 | load_raw: function(arr, callback) { 978 | if (!load_setup(callback)) return; 979 | if (!initialized) init(); 980 | stream = new Stream(arr); 981 | setTimeout(doParse, 0); 982 | }, 983 | set_frame_offset: setFrameOffset 984 | }; 985 | }; 986 | 987 | return SuperGif; 988 | })); 989 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scrolly-gif", 3 | "version": "1.0.1", 4 | "description": "Animate a gif based on user scroll", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/mathisonian/scrolly-gif.git" 12 | }, 13 | "keywords": [ 14 | "gif", 15 | "scroll", 16 | "animate", 17 | "scrolly" 18 | ], 19 | "author": "Matthew Conlen", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/mathisonian/scrolly-gif/issues" 23 | }, 24 | "homepage": "https://github.com/mathisonian/scrolly-gif#readme" 25 | } 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | var SuperGif = require('../lib/lib-gif'); 2 | 3 | module.exports = function(elt) { 4 | 5 | var rect = elt.getBoundingClientRect(); 6 | var gif = SuperGif({ gif: elt, auto_play: false }); 7 | 8 | var height = rect.bottom - rect.top; 9 | 10 | var absoluteTop = rect.top + window.scrollY; 11 | var absoluteBottom = rect.bottom + window.scrollY; 12 | var absolutePosition = rect.top + window.scrollY + height / 2; 13 | 14 | gif.load(function() { 15 | gif.move_to(0); 16 | var gifLength = gif.get_length(); 17 | 18 | window.addEventListener('scroll', function(e) { 19 | // calculate current position based on scroll position 20 | var windowHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight; 21 | var scrollY = window.scrollY; 22 | 23 | if((absoluteBottom - scrollY) >= windowHeight || (absoluteBottom - scrollY) <= 0) { 24 | return; 25 | } else { 26 | var position = (windowHeight - height) - (absoluteTop - scrollY); 27 | position /= (windowHeight - height); 28 | var mappedIndex = position * gifLength; 29 | mappedIndex = Math.min(Math.round(mappedIndex), gifLength - 1); 30 | gif.move_to(mappedIndex); 31 | } 32 | }); 33 | }); 34 | } 35 | --------------------------------------------------------------------------------