diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..7d06030 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": [], + "plugins": ["transform-class-properties"] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d2b47d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +node_modules \ No newline at end of file diff --git a/demo.html b/demo.html new file mode 100644 index 0000000..1e1d979 --- /dev/null +++ b/demo.html @@ -0,0 +1,81 @@ + + + + + + Title + + + + +
+ + + \ No newline at end of file diff --git a/dist/img-2.js b/dist/img-2.js new file mode 100644 index 0000000..972224e --- /dev/null +++ b/dist/img-2.js @@ -0,0 +1,351 @@ +/** + * Created by Leon Revill on 10/12/2017. + * Blog: blog.revillweb.com + * Twitter: @RevillWeb + * GitHub: github.com/RevillWeb + */ +const __style__ = Symbol(); + +class Img2 extends HTMLElement { + + constructor() { + super(); + + // Private class variables + this._root = null; + this._$img = null; + this._$preview = null; + this._preview = null; + this._src = null; + this._width = null; + this._height = null; + this._reset(); + + // Settings + this._renderOnPreCached = Img2.settings.RENDER_ON_PRECACHED; + + // Bound class methods + this._precache = this._precache.bind(this); + this._onImgLoad = this._onImgLoad.bind(this); + this._onImgPreCached = this._onImgPreCached.bind(this); + } + + /** + * Reset all private values + * @private + */ + _reset() { + this._rendered = false; + this._loading = false; + this._loaded = false; + this._preCaching = false; + this._preCached = false; + } + + [__style__]() { + return ` + + `; + } + + connectedCallback() { + + // Override any global settings + this._renderOnPreCached = this.getAttribute("render-on-pre-cached") === "true"; + this._init(); + } + + _init() { + + // Check to see if we have a src, if not return and do nothing else + this._src = this.getAttribute("src"); + // Grab the initial attribute values + this._preview = this.getAttribute("src-preview"); + this._width = this.getAttribute("width"); + this._height = this.getAttribute("height"); + + if (!this._src || !this._width || !this._height) return; + + // Set the height and width of the element so that we can figure out if it is on the screen or not + this.style.width = `${ this._width }px`; + this.style.height = `${ this._height }px`; + + // Figure out if this image is within view + Img2.addIntersectListener(this, () => { + Img2._removePreCacheListener(this._precache); + this._render(); + this._load(); + Img2.removeIntersectListener(this); + }); + + // Listen for precache instruction + Img2._addPreCacheListener(this._precache, this._src); + } + + /** + * Method which displays the image once ready to be displayed + * @private + */ + _load() { + if (this._preCached === false) Img2._priorityCount += 1; + this._$img.onload = this._onImgLoad; + this._loading = true; + this._$img.src = this._src; + } + + _onImgLoad() { + this._loading = false; + this._loaded = true; + if (this._$preview !== null) { + this._root.removeChild(this._$preview); + this._$preview = null; + } + this._$img.onload = null; + if (this._preCached === false) Img2._priorityCount -= 1; + } + + _onImgPreCached() { + this._preCaching = false; + this._preCached = true; + if (this._renderOnPreCached !== false) { + this._render(); + this._load(); + } + } + + static get observedAttributes() { + return ["src", "width", "height"]; + } + attributeChangedCallback(name, oldValue, newValue) { + + // If nothing has changed then just return + if (newValue === oldValue) return; + + switch (name) { + case "src": + // If the src is changed then we need to reset and start again + this._reset(); + this._init(); + break; + case "width": + this._width = newValue; + if (this._$preview !== null) { + this._$preview.width = this._width; + } + if (this._$img !== null) { + this._$img.width = this._width; + } + this.style.width = `${ this._width }px`; + break; + case "height": + this._height = newValue; + if (this._$preview !== null) { + this._$preview.height = this._height; + } + if (this._$img !== null) { + this._$img.height = this._height; + } + this.style.height = `${ this._height }px`; + break; + case "render-on-pre-cached": + this._renderOnPreCached = !(this.getAttribute("render-on-pre-cached") === "false"); + break; + } + } + + /** + * Method which renders the DOM elements and displays any preview image + * @private + */ + _render() { + + if (this._rendered === true) return; + + // Render the Shadow Root if not done already (src change can force this method to be called again) + if (this._root === null) { + // Attach the Shadow Root to the element + this._root = this.attachShadow({ mode: "open" }); + // Create the initial template with styles + this._root.innerHTML = `${ this[__style__]() }`; + } + + // If a preview image has been specified + if (this._$preview === null && this._preview !== null && this._loaded === false) { + // Create the element + this._$preview = document.createElement("img"); + this._$preview.classList.add("img2-preview"); + this._$preview.src = this._preview; + // Add the specified width and height + this._$preview.width = this._width; + this._$preview.height = this._height; + // Add it to the Shadow Root + this._root.appendChild(this._$preview); + } + + // Render the img element if not done already + if (this._$img === null) { + // Create the actual image element to be used to display the image + this._$img = document.createElement("img"); + this._$img.classList.add("img2-src"); + // add the specified width and height to the image element + this._$img.width = this._width; + this._$img.height = this._height; + // Add the image to the Shadow Root + this._root.appendChild(this._$img); + } + + // Flag as rendered + this._rendered = true; + } + + _precache() { + this._preCaching = true; + Img2._preCache(this._src, this._onImgPreCached); + } + + static _addPreCacheListener(cb, url) { + Img2._preCacheListeners.set(cb, url); + } + + static _removePreCacheListener(cb) { + Img2._preCacheListeners.delete(cb); + } + + static _startPreCache() { + for (let cb of Img2._preCacheListeners.keys()) cb(); + } + + /** + * Methods used to determine when currently visible (priority) elements have finished download to then inform other elements to pre-cache + */ + + static get _priorityCount() { + return Img2.__priorityCount; + } + static set _priorityCount(value) { + Img2.__priorityCount = value; + if (Img2.__priorityCount < 1) { + // Inform components that they can start to pre-cache their images + // Debounce in case the user scrolls because then there will be more priority images + if (Img2._startPreCacheDebounce !== null) { + clearTimeout(Img2._startPreCacheDebounce); + Img2._startPreCacheDebounce = null; + } + Img2._startPreCacheDebounce = setTimeout(function () { + if (Img2.__priorityCount < 1) Img2._startPreCache(); + }, 500); + } + } + + /** + * Methods used to determine when this element is in the visible viewport + */ + + + static addIntersectListener($element, intersectCallback) { + Img2._intersectListeners.set($element, intersectCallback); + Img2._observer.observe($element); + } + + static removeIntersectListener($element) { + if ($element) Img2._observer.unobserve($element); + } + + static _handleIntersect(entries) { + entries.forEach(entry => { + if (entry.isIntersecting === true) { + const cb = Img2._intersectListeners.get(entry.target); + if (cb !== undefined) cb(entry); + } + }); + } + + static _preCache(url, cb) { + + let slot = Img2._preCacheCallbacks[url]; + if (slot === undefined) { + Img2._preCacheCallbacks[url] = { + cached: false, + cbs: [cb] + }; + const location = url.indexOf("http") > -1 ? url : window.location.href + url; + Img2._worker.postMessage({ location: location, url: url }); + } else { + if (slot.cached === true) { + cb(); + } else { + slot.cbs.push(cb); + } + } + } +} + +/** + * Methods used to pre-cache images using a WebWorker + */ + +Img2._preCacheListeners = new Map(); +Img2.__priorityCount = 0; +Img2._startPreCacheDebounce = null; +Img2._intersectListeners = new Map(); +Img2._observer = new IntersectionObserver(Img2._handleIntersect, { + root: null, + rootMargin: "0px", + threshold: 0 +}); +Img2._preCacheCallbacks = {}; +Img2._worker = new Worker(window.URL.createObjectURL(new Blob([`self.onmessage=${ function (e) { + fetch(e.data.location).then(response => { + if (response.status === 200 || response.status === 0) { + return Promise.resolve(response); + } else { + return Promise.reject(new Error(`Couldn't pre-cache URL '${ e.data.url }'.`)); + } + }).then(response => { + return response.blob(); + }).then(() => { + self.postMessage(e.data.url); + }).catch(console.error); +}.toString() };`], { type: "text/javascript" }))); + +Img2._worker.onmessage = function (e) { + const slot = Img2._preCacheCallbacks[e.data]; + if (slot !== undefined) { + slot.cached = true; + slot.cbs = slot.cbs.filter(cb => { + // Call the callback + cb(); + // Remove the callback + return false; + }); + } +}; + +/** Img2 Settings **/ +Img2.settings = { + "RENDER_ON_PRECACHED": false // Set this to false to save memory but can cause jank during scrolling +}; + +window.customElements.define("img-2", Img2); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1d08edd --- /dev/null +++ b/package-lock.json @@ -0,0 +1,483 @@ +{ + "name": "img-2", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "dev": true, + "requires": { + "chalk": "1.1.3", + "esutils": "2.0.2", + "js-tokens": "3.0.2" + } + }, + "babel-core": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.0.tgz", + "integrity": "sha1-rzL3izGm/O8RnIew/Y2XU/A6C7g=", + "dev": true, + "requires": { + "babel-code-frame": "6.26.0", + "babel-generator": "6.26.0", + "babel-helpers": "6.24.1", + "babel-messages": "6.23.0", + "babel-register": "6.26.0", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "convert-source-map": "1.5.1", + "debug": "2.6.9", + "json5": "0.5.1", + "lodash": "4.17.4", + "minimatch": "3.0.4", + "path-is-absolute": "1.0.1", + "private": "0.1.8", + "slash": "1.0.0", + "source-map": "0.5.7" + } + }, + "babel-generator": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.0.tgz", + "integrity": "sha1-rBriAHC3n248odMmlhMFN3TyDcU=", + "dev": true, + "requires": { + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "detect-indent": "4.0.0", + "jsesc": "1.3.0", + "lodash": "4.17.4", + "source-map": "0.5.7", + "trim-right": "1.0.1" + } + }, + "babel-helper-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz", + "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=", + "dev": true, + "requires": { + "babel-helper-get-function-arity": "6.24.1", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helper-get-function-arity": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz", + "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-types": "6.26.0" + } + }, + "babel-helpers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz", + "integrity": "sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-messages": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", + "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0" + } + }, + "babel-plugin-syntax-class-properties": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-class-properties/-/babel-plugin-syntax-class-properties-6.13.0.tgz", + "integrity": "sha1-1+sjt5oxf4VDlixQW4J8fWysJ94=", + "dev": true + }, + "babel-plugin-transform-class-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-class-properties/-/babel-plugin-transform-class-properties-6.24.1.tgz", + "integrity": "sha1-anl2PqYdM9NvN7YRqp3vgagbRqw=", + "dev": true, + "requires": { + "babel-helper-function-name": "6.24.1", + "babel-plugin-syntax-class-properties": "6.13.0", + "babel-runtime": "6.26.0", + "babel-template": "6.26.0" + } + }, + "babel-register": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz", + "integrity": "sha1-btAhFz4vy0htestFxgCahW9kcHE=", + "dev": true, + "requires": { + "babel-core": "6.26.0", + "babel-runtime": "6.26.0", + "core-js": "2.5.2", + "home-or-tmp": "2.0.0", + "lodash": "4.17.4", + "mkdirp": "0.5.1", + "source-map-support": "0.4.18" + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "dev": true, + "requires": { + "core-js": "2.5.2", + "regenerator-runtime": "0.11.1" + } + }, + "babel-template": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz", + "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "babel-traverse": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "lodash": "4.17.4" + } + }, + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "dev": true, + "requires": { + "babel-code-frame": "6.26.0", + "babel-messages": "6.23.0", + "babel-runtime": "6.26.0", + "babel-types": "6.26.0", + "babylon": "6.18.0", + "debug": "2.6.9", + "globals": "9.18.0", + "invariant": "2.2.2", + "lodash": "4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "dev": true, + "requires": { + "babel-runtime": "6.26.0", + "esutils": "2.0.2", + "lodash": "4.17.4", + "to-fast-properties": "1.0.3" + } + }, + "babylon": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", + "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.8.tgz", + "integrity": "sha1-wHshHHyVLsH479Uad+8NHTmQopI=", + "dev": true, + "requires": { + "balanced-match": "1.0.0", + "concat-map": "0.0.1" + } + }, + "chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "2.2.1", + "escape-string-regexp": "1.0.5", + "has-ansi": "2.0.0", + "strip-ansi": "3.0.1", + "supports-color": "2.0.0" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "convert-source-map": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.5.1.tgz", + "integrity": "sha1-uCeAl7m8IpNl3lxiz1/K7YtVmeU=", + "dev": true + }, + "core-js": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.2.tgz", + "integrity": "sha1-vEZIZW59ydyA19PHu8Fy2W50TmM=", + "dev": true + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "detect-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", + "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=", + "dev": true, + "requires": { + "repeating": "2.0.1" + } + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "globals": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", + "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==", + "dev": true + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "home-or-tmp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz", + "integrity": "sha1-42w/LSyufXRqhX440Y1fMqeILbg=", + "dev": true, + "requires": { + "os-homedir": "1.0.2", + "os-tmpdir": "1.0.2" + } + }, + "invariant": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.2.tgz", + "integrity": "sha1-nh9WrArNtr8wMwbzOL47IErmA2A=", + "dev": true, + "requires": { + "loose-envify": "1.3.1" + } + }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "dev": true, + "requires": { + "number-is-nan": "1.0.1" + } + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "dev": true + }, + "jsesc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=", + "dev": true + }, + "json5": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", + "dev": true + }, + "lodash": { + "version": "4.17.4", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.4.tgz", + "integrity": "sha1-eCA6TRwyiuHYbcpkYONptX9AVa4=", + "dev": true + }, + "loose-envify": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", + "integrity": "sha1-0aitM/qc4OcT1l/dCsi3SNR4yEg=", + "dev": true, + "requires": { + "js-tokens": "3.0.2" + } + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "1.1.8" + } + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "dev": true + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", + "dev": true + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "dev": true, + "requires": { + "is-finite": "1.0.2" + } + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, + "requires": { + "source-map": "0.5.7" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "2.1.1" + } + }, + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + }, + "to-fast-properties": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", + "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=", + "dev": true + }, + "trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d1d9c47 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "img-2", + "version": "1.0.0", + "description": "Replace elements with to automatically pre-cache images and improve page performance.", + "main": "index.js", + "scripts": { + "build": "babel src/img-2.js --out-file dist/img-2.js --watch" + }, + "keywords": [ + "pre-cache", + "pre-load" + ], + "author": "Leon Revill (@revillweb)", + "license": "MIT", + "devDependencies": { + "babel-core": "^6.26.0", + "babel-plugin-transform-class-properties": "^6.24.1" + } +} diff --git a/src/img-2.js b/src/img-2.js new file mode 100644 index 0000000..7647150 --- /dev/null +++ b/src/img-2.js @@ -0,0 +1,355 @@ +/** + * Created by Leon Revill on 10/12/2017. + * Blog: blog.revillweb.com + * Twitter: @RevillWeb + * GitHub: github.com/RevillWeb + */ +const __style__ = Symbol(); + +class Img2 extends HTMLElement { + + constructor() { + super(); + + // Private class variables + this._root = null; + this._$img = null; + this._$preview = null; + this._preview = null; + this._src = null; + this._width = null; + this._height = null; + this._reset(); + + // Settings + this._renderOnPreCached = Img2.settings.RENDER_ON_PRECACHED; + + // Bound class methods + this._precache = this._precache.bind(this); + this._onImgLoad = this._onImgLoad.bind(this); + this._onImgPreCached = this._onImgPreCached.bind(this); + } + + /** + * Reset all private values + * @private + */ + _reset() { + this._rendered = false; + this._loading = false; + this._loaded = false; + this._preCaching = false; + this._preCached = false; + } + + [__style__]() { + return ` + + `; + } + + connectedCallback() { + + // Override any global settings + this._renderOnPreCached = (this.getAttribute("render-on-pre-cached") === "true"); + this._init(); + + } + + _init() { + + // Check to see if we have a src, if not return and do nothing else + this._src = this.getAttribute("src"); + // Grab the initial attribute values + this._preview = this.getAttribute("src-preview"); + this._width = this.getAttribute("width"); + this._height = this.getAttribute("height"); + + if (!this._src || !this._width || !this._height) return; + + // Set the height and width of the element so that we can figure out if it is on the screen or not + this.style.width = `${this._width}px`; + this.style.height = `${this._height}px`; + + // Figure out if this image is within view + Img2.addIntersectListener(this, () => { + Img2._removePreCacheListener(this._precache); + this._render(); + this._load(); + Img2.removeIntersectListener(this); + }); + + // Listen for precache instruction + Img2._addPreCacheListener(this._precache, this._src); + + } + + /** + * Method which displays the image once ready to be displayed + * @private + */ + _load() { + if (this._preCached === false) Img2._priorityCount += 1; + this._$img.onload = this._onImgLoad; + this._loading = true; + this._$img.src = this._src; + } + + _onImgLoad() { + this._loading = false; + this._loaded = true; + if (this._$preview !== null) { + this._root.removeChild(this._$preview); + this._$preview = null; + } + this._$img.onload = null; + if (this._preCached === false) Img2._priorityCount -= 1; + } + + _onImgPreCached() { + this._preCaching = false; + this._preCached = true; + if (this._renderOnPreCached !== false) { + this._render(); + this._load(); + } + } + + static get observedAttributes() { + return ["src", "width", "height"]; + } + attributeChangedCallback(name, oldValue, newValue) { + + // If nothing has changed then just return + if (newValue === oldValue) return; + + switch (name) { + case "src": + // If the src is changed then we need to reset and start again + this._reset(); + this._init(); + break; + case "width": + this._width = newValue; + if (this._$preview !== null) { + this._$preview.width = this._width; + } + if (this._$img !== null) { + this._$img.width = this._width; + } + this.style.width = `${this._width}px`; + break; + case "height": + this._height = newValue; + if (this._$preview !== null) { + this._$preview.height = this._height; + } + if (this._$img !== null) { + this._$img.height = this._height; + } + this.style.height = `${this._height}px`; + break; + case "render-on-pre-cached": + this._renderOnPreCached = !(this.getAttribute("render-on-pre-cached") === "false"); + break; + } + } + + /** + * Method which renders the DOM elements and displays any preview image + * @private + */ + _render() { + + if (this._rendered === true) return; + + // Render the Shadow Root if not done already (src change can force this method to be called again) + if (this._root === null) { + // Attach the Shadow Root to the element + this._root = this.attachShadow({mode: "open"}); + // Create the initial template with styles + this._root.innerHTML = `${this[__style__]()}`; + } + + // If a preview image has been specified + if (this._$preview === null && this._preview !== null && this._loaded === false) { + // Create the element + this._$preview = document.createElement("img"); + this._$preview.classList.add("img2-preview"); + this._$preview.src = this._preview; + // Add the specified width and height + this._$preview.width = this._width; + this._$preview.height = this._height; + // Add it to the Shadow Root + this._root.appendChild(this._$preview); + } + + // Render the img element if not done already + if (this._$img === null) { + // Create the actual image element to be used to display the image + this._$img = document.createElement("img"); + this._$img.classList.add("img2-src"); + // add the specified width and height to the image element + this._$img.width = this._width; + this._$img.height = this._height; + // Add the image to the Shadow Root + this._root.appendChild(this._$img); + } + + // Flag as rendered + this._rendered = true; + + } + + _precache() { + this._preCaching = true; + Img2._preCache(this._src, this._onImgPreCached); + } + + static _preCacheListeners = new Map(); + static _addPreCacheListener(cb, url) { + Img2._preCacheListeners.set(cb, url); + } + + static _removePreCacheListener(cb) { + Img2._preCacheListeners.delete(cb); + } + + static _startPreCache() { + for (let cb of Img2._preCacheListeners.keys()) cb(); + } + + /** + * Methods used to determine when currently visible (priority) elements have finished download to then inform other elements to pre-cache + */ + + static __priorityCount = 0; + static _startPreCacheDebounce = null; + static get _priorityCount() { + return Img2.__priorityCount; + } + static set _priorityCount(value) { + Img2.__priorityCount = value; + if (Img2.__priorityCount < 1) { + // Inform components that they can start to pre-cache their images + // Debounce in case the user scrolls because then there will be more priority images + if (Img2._startPreCacheDebounce !== null) { + clearTimeout(Img2._startPreCacheDebounce); + Img2._startPreCacheDebounce = null; + } + Img2._startPreCacheDebounce = setTimeout(function(){ + if (Img2.__priorityCount < 1) Img2._startPreCache(); + }, 500); + } + } + + /** + * Methods used to determine when this element is in the visible viewport + */ + static _intersectListeners = new Map(); + static _observer = new IntersectionObserver(Img2._handleIntersect, { + root: null, + rootMargin: "0px", + threshold: 0 + }); + + static addIntersectListener($element, intersectCallback) { + Img2._intersectListeners.set($element, intersectCallback); + Img2._observer.observe($element); + } + + static removeIntersectListener($element) { + if ($element) Img2._observer.unobserve($element); + } + + static _handleIntersect(entries) { + entries.forEach(entry => { + if (entry.isIntersecting === true) { + const cb = Img2._intersectListeners.get(entry.target); + if (cb !== undefined) cb(entry); + } + }); + } + + static _preCacheCallbacks = {}; + static _preCache(url, cb) { + + let slot = Img2._preCacheCallbacks[url]; + if (slot === undefined) { + Img2._preCacheCallbacks[url] = { + cached: false, + cbs: [cb] + }; + const location = (url.indexOf("http") > -1) ? url : window.location.href + url; + Img2._worker.postMessage({ location: location, url: url }); + } else { + if (slot.cached === true) { + cb(); + } else { + slot.cbs.push(cb); + } + } + } +} + +/** + * Methods used to pre-cache images using a WebWorker + */ + +Img2._worker = new Worker(window.URL.createObjectURL( + new Blob([`self.onmessage=${function (e) { + fetch(e.data.location).then((response) => { + if (response.status === 200 || response.status === 0) { + return Promise.resolve(response) + } else { + return Promise.reject(new Error(`Couldn't pre-cache URL '${e.data.url}'.`)); + } + }).then((response) => { + return response.blob(); + }).then(() => { + self.postMessage(e.data.url); + }).catch(console.error); + }.toString()};`], { type: "text/javascript"}) +)); + +Img2._worker.onmessage = function (e) { + const slot = Img2._preCacheCallbacks[e.data]; + if (slot !== undefined) { + slot.cached = true; + slot.cbs = slot.cbs.filter(cb => { + // Call the callback + cb(); + // Remove the callback + return false; + }); + } +}; + +/** Img2 Settings **/ +Img2.settings = { + "RENDER_ON_PRECACHED": false // Set this to false to save memory but can cause jank during scrolling +}; + +window.customElements.define("img-2", Img2);