├── .babelrc ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package.json ├── renovate.json └── source ├── index.js ├── read-blob-file.js └── spinner.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "es2016", "stage-0", "react"], 3 | "plugins": [ 4 | "transform-merge-sibling-variables", 5 | "transform-minify-booleans", 6 | "transform-node-env-inline", 7 | "transform-react-constant-elements", 8 | "transform-react-inline-elements", 9 | "transform-remove-console", 10 | "transform-remove-debugger", 11 | "transform-simplify-comparison-operators", 12 | "transform-undefined-to-void" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "plugins": [ 5 | "import", 6 | "jsx-a11y", 7 | "react", 8 | "flowtype", 9 | ], 10 | "env": { 11 | "browser": true, 12 | "node": true, 13 | }, 14 | "parserOptions": { 15 | "ecmaFeatures": { 16 | "jsx": true, 17 | "es6": true, 18 | "classes": true, 19 | }, 20 | }, 21 | "settings": { 22 | "react": { 23 | "pragma": "React", 24 | "version": "15.3", 25 | }, 26 | "flowtype": { 27 | "onlyFilesWithFlowAnnotation": false, 28 | }, 29 | }, 30 | "rules": { 31 | "flowtype/require-parameter-type": 1, 32 | "flowtype/require-return-type": [ 33 | 1, 34 | "always", 35 | { 36 | "annotateUndefined": "never" 37 | }, 38 | ], 39 | "flowtype/space-after-type-colon": [ 40 | 1, 41 | "always", 42 | ], 43 | "flowtype/space-before-type-colon": [ 44 | 1, 45 | "never", 46 | ], 47 | "flowtype/type-id-match": [ 48 | 1, 49 | "^([A-Z][a-z0-9]+)+Type$", 50 | ], 51 | }, 52 | } 53 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [include] 2 | source/ 3 | -------------------------------------------------------------------------------- /.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 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | build 40 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | source 3 | .babelrc 4 | .eslintrc 5 | .gitignore 6 | logs 7 | *.log 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Sergio Daniel Xalambrí 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-lazy-image 2 | 3 | Component to render images and lazyload them if are in the viewport (or near to them). 4 | 5 | This component extends from `React.PureComponent` so it needs React.js v15.3.0 or superior. 6 | 7 | 8 | 9 | 10 | 11 | ## Installation 12 | ```bash 13 | npm i -S react-lazy-image 14 | ``` 15 | 16 | ## Usage example 17 | ```jsx 18 | import Image from 'react-lazy-image'; 19 | 20 | const image = ; 21 | ``` 22 | 23 | ## API 24 | 25 | ### `onLayout({ element: Object }): void` 26 | Called everytime the component is rendered or updated. Default: `() => {}`. 27 | 28 | ### `onError({ element: Object, error: Error }): void` 29 | Called if the request to load the image failed. Default: `() => {}`. 30 | 31 | ### `onLoad({ element: Object }): void` 32 | Called after the load ended (either successfully or not). Default: `() => {}`. 33 | 34 | ### `onLoadEnd({ element: Object }): void` 35 | Called after the load ended successfully. Default: `() => {}`. 36 | 37 | ### `onLoadStart({ element: Object }): void` 38 | Called when the request started. Default: `() => {}`. 39 | 40 | ### `onAbort({ element: Object }): void` 41 | Called if the load of the image was aborted. Default: `() => {}`. 42 | 43 | ### `onProgress({ element: Object }): void` 44 | Called everytime the AJAX progress event is dispatched. Default: `() => {}`. 45 | 46 | ### `offset: ?number` 47 | Set the amount of pixel near the viewport the component should be to start the image load. Default: `0`. 48 | 49 | ### `source: string` 50 | The image source path to load. 51 | 52 | ### `defaultSource: ?string` 53 | The default image source path or base64. If isn't defined then it uses a SVG animated spinner. 54 | 55 | ### `type: ?string` 56 | The format type of the image (`png`, `svg+xml`, `jpg` or `gif`). Default: `*`. 57 | 58 | ### `minLoaded: ?number` 59 | The minimum download percentaje to avoid aborting the request if the image leaves the viewport. Default: `50`. 60 | 61 | ## Common `img` attributes 62 | This component allow the usage of common `img` attributes like `alt`, `width`, `className`, etc. So you can use it as a normal `img` tag, just change `src` to `source` and (if you want to) add the other optional props. 63 | 64 | ## License 65 | The MIT License (MIT) 66 | 67 | Copyright (c) 2015 Sergio Daniel Xalambrí 68 | 69 | Permission is hereby granted, free of charge, to any person obtaining a copy 70 | of this software and associated documentation files (the "Software"), to deal 71 | in the Software without restriction, including without limitation the rights 72 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 73 | copies of the Software, and to permit persons to whom the Software is 74 | furnished to do so, subject to the following conditions: 75 | 76 | The above copyright notice and this permission notice shall be included in all 77 | copies or substantial portions of the Software. 78 | 79 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 80 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 81 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 82 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 83 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 84 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 85 | SOFTWARE. 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-lazy-image", 3 | "version": "1.1.0", 4 | "description": "Component to render images and lazyload them if are in the viewport (or near to them).", 5 | "main": "build/index.js", 6 | "scripts": { 7 | "lint": "eslint lib/index.js", 8 | "prebuild": "npm run lint", 9 | "build": "babel source -d build", 10 | "prepublish": "npm run build" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/sergiodxa/react-lazy-image.git" 15 | }, 16 | "keywords": [ 17 | "react", 18 | "lazyload", 19 | "image", 20 | "ajax" 21 | ], 22 | "author": "Sergio Daniel Xalambrí (http://sergio.xalambri.com.ar/)", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/sergiodxa/react-lazy-image/issues" 26 | }, 27 | "homepage": "https://github.com/sergiodxa/react-lazy-image#readme", 28 | "peerDependencies": { 29 | "react": "^15.0.0" 30 | }, 31 | "devDependencies": { 32 | "babel": "6.23.0", 33 | "babel-cli": "6.26.0", 34 | "babel-eslint": "6.1.2", 35 | "babel-plugin-transform-merge-sibling-variables": "6.8.0", 36 | "babel-plugin-transform-minify-booleans": "6.9.4", 37 | "babel-plugin-transform-node-env-inline": "6.8.0", 38 | "babel-plugin-transform-react-constant-elements": "6.23.0", 39 | "babel-plugin-transform-react-inline-elements": "6.22.0", 40 | "babel-plugin-transform-remove-console": "6.8.0", 41 | "babel-plugin-transform-remove-debugger": "6.9.4", 42 | "babel-plugin-transform-simplify-comparison-operators": "6.8.0", 43 | "babel-plugin-transform-undefined-to-void": "6.9.4", 44 | "babel-preset-es2015": "6.24.1", 45 | "babel-preset-es2016": "6.24.1", 46 | "babel-preset-react": "6.24.1", 47 | "babel-preset-stage-0": "6.24.1", 48 | "eslint": "3.2.2", 49 | "eslint-config-airbnb": "10.0.1", 50 | "eslint-plugin-flowtype": "2.4.0", 51 | "eslint-plugin-import": "1.16.0", 52 | "eslint-plugin-jsx-a11y": "2.0.1", 53 | "eslint-plugin-react": "6.0.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /source/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | 3 | import defaultSource from './spinner.js'; 4 | import readBlobFile from './read-blob-file.js'; 5 | 6 | type RectType = { 7 | bottom: number, 8 | height: number, 9 | left: number, 10 | right: number, 11 | top: number, 12 | width: number, 13 | }; 14 | 15 | type PropType = { 16 | onLayout(event: { element: Object }): void, 17 | onError(event: { element: Object, error: Error }): void, 18 | onLoad(event: { element: Object }): void, 19 | onLoadEnd(event: { element: Object }): void, 20 | onLoadStart(event: { element: Object }): void, 21 | onAbort(event: { element: Object }): void, 22 | onProgress(event: { element: Object }): void, 23 | offset: ?number, 24 | source: string, 25 | defaultSource: ?string, 26 | type: ?string, 27 | minLoaded: ?number, 28 | }; 29 | 30 | /** 31 | * Instance the default props 32 | * @type {PropTypes} 33 | */ 34 | const defaultProps: PropType = { 35 | onLayout: () => {}, 36 | onError: () => {}, 37 | onLoad: () => {}, 38 | onLoadEnd: () => {}, 39 | onLoadStart: () => {}, 40 | onAbort: () => {}, 41 | onProgress: () => {}, 42 | offset: 0, 43 | defaultSource, 44 | type: '*', 45 | minLoaded: 50, 46 | }; 47 | 48 | /** 49 | * Component to render an image using LazyLoad to request it only if the component is in 50 | * the viewport and abort the load if the component leaves the viewport 51 | */ 52 | class Image extends Component { 53 | static defaultProps = defaultProps; 54 | 55 | /** 56 | * Bind component methods to `this` 57 | * @param {PropType} props [description] 58 | */ 59 | constructor(props: PropType) { 60 | super(props); 61 | // bind function methods 62 | this.checkViewport = this.checkViewport.bind(this); 63 | this.setRef = this.setRef.bind(this); 64 | 65 | this.handleLoadEnd = this.handleLoadEnd.bind(this); 66 | this.handleAbort = this.handleAbort.bind(this); 67 | this.handleProgress = this.handleProgress.bind(this); 68 | this.handleError = this.handleError.bind(this); 69 | this.handleLoadStart = this.handleLoadStart.bind(this); 70 | this.handleLoad = this.handleLoad.bind(this); 71 | } 72 | 73 | 74 | /** 75 | * The state contains the image base64 string to use 76 | * @type {Object} 77 | */ 78 | state: { image: string } = { 79 | image: this.props.defaultSource, 80 | }; 81 | 82 | 83 | /** 84 | * Call the onLayout after the component is renderer 85 | * Create the XHR object 86 | * Set the event listener for the scroll event to check if the component is in viewport 87 | * Check the viewport one time to now if it's already in it 88 | */ 89 | componentDidMount() { 90 | this.props.onLayout({ element: this }); 91 | 92 | this.request = new XMLHttpRequest(); 93 | this.request.responseType = 'arraybuffer'; 94 | 95 | window.addEventListener('scroll', this.checkViewport); 96 | this.checkViewport(); 97 | } 98 | 99 | 100 | /** 101 | * Call the onLayout callback if the component is re-rendered 102 | */ 103 | componentDidUpdate() { 104 | this.props.onLayout({ element: this }); 105 | } 106 | 107 | 108 | /** 109 | * Remove scroll event listener if the component is unmounted 110 | */ 111 | componentWillUnmount() { 112 | window.removeEventListener('scroll', this.checkViewport); 113 | this.request.abort(); 114 | } 115 | 116 | 117 | /** 118 | * Get the reference to the image 119 | * @param {Element} element The DOM element to set the reference 120 | */ 121 | setRef(element: Element): Object { 122 | this.element = element; 123 | return this; 124 | } 125 | 126 | 127 | /** 128 | * Fetch the image and save it in the state 129 | */ 130 | fetch(): void { 131 | // set request event handlers 132 | this.request.onloadstart = this.handleLoadStart; 133 | this.request.onprogress = this.handleProgress; 134 | this.request.onload = this.handleLoad; 135 | this.request.onloadend = this.handleLoadEnd; 136 | this.request.onabort = this.handleAbort; 137 | this.request.onerror = this.handleError; 138 | 139 | // open AJAX request 140 | this.request.open('GET', this.props.source); 141 | // send request 142 | return this.request.send(); 143 | } 144 | 145 | 146 | /** 147 | * Handle load start event 148 | * @param {Object} event Request start event object 149 | */ 150 | handleLoadStart(event: Object): void { 151 | this.isRequesting = true; 152 | const element = this; 153 | 154 | if (event.lengthComputable) { 155 | this.progress.loaded = 0; 156 | this.progress.total = event.total; 157 | } 158 | 159 | return this.props.onLoadStart({ element }); 160 | } 161 | 162 | 163 | /** 164 | * Set the loaded progress (and the total) 165 | * @param {Object} event The progress event data 166 | */ 167 | handleProgress(event: Object): void { 168 | const element = this; 169 | 170 | if (event.lengthComputable) { 171 | this.progress.loaded = event.loaded; 172 | } 173 | 174 | return this.props.onProgress({ element }); 175 | } 176 | 177 | 178 | /** 179 | * Handle the XHR load event 180 | * @param {Object} event The load event data 181 | */ 182 | handleLoad(): void { 183 | const element: Image = this; 184 | this.isRequesting = false; 185 | return this.props.onLoad({ element }); 186 | } 187 | 188 | 189 | /** 190 | * If the request ended successful get the response as a blob object, transform it to base64, 191 | * remove the scroll event listener and update the `state.image` value` 192 | * @param {Object} event The load end event data 193 | */ 194 | handleLoadEnd(): void { 195 | const element: Image = this; 196 | 197 | // if the request status es between 200 and 300 198 | if (this.request.status >= 200 && this.request.status < 300) { 199 | // transform response to a blob 200 | const blob: Blob = new Blob([this.request.response], { type: `image/${this.props.type}` }); 201 | // read blob as a base64 string 202 | return readBlobFile(blob) 203 | .then((image: string) => { 204 | // set the image base64 string in the state 205 | this.setState({ image }, (): void => { 206 | // remove event scroll listener 207 | window.removeEventListener('scroll', this.checkViewport); 208 | // set the component as not requesting anymore 209 | this.isRequesting = false; 210 | // call the `onLoadEnd` callback 211 | return this.props.onLoadEnd({ element }); 212 | }); 213 | }) 214 | .catch((error: Object): void => { 215 | // set the component as not requesting anymore 216 | this.isRequesting = false; 217 | // if an error happens call the `onError` callback 218 | return this.props.onError({ error, element }); 219 | }); 220 | } 221 | 222 | return null; 223 | } 224 | 225 | 226 | /** 227 | * Handle request error event 228 | * @param {Object} event The error event data 229 | */ 230 | handleError(event: Object): void { 231 | const element: Image = this; 232 | this.isRequesting = false; 233 | return this.props.onError({ element, error: new Error(event.response) }); 234 | } 235 | 236 | 237 | /** 238 | * Handle the request abort event 239 | * @param {Object} event The abort event data 240 | */ 241 | handleAbort(): void { 242 | const element = this; 243 | this.isRequesting = false; 244 | return this.props.onAbort({ element }); 245 | } 246 | 247 | 248 | /** 249 | * Check if the component is in the current viewport and load the image 250 | */ 251 | checkViewport(): Promise | null { 252 | if (this.isInViewport && !this.isRequesting) { 253 | // if is in viewport and is not requesting start fetching 254 | return this.fetch(); 255 | } 256 | if (!this.isInViewport && this.isRequesting) { 257 | // if isn't in viewport and is requesting 258 | if (this.amountLoaded < this.props.minLoaded || isNaN(this.amountLoaded)) { 259 | // if the amount loaded is lower than the `this.props.minLoaded` 260 | // or is NaN abort the request 261 | return this.request.abort(); 262 | } 263 | } 264 | return null; 265 | } 266 | 267 | 268 | /** 269 | * Define the prop types 270 | * @type {PropType} 271 | */ 272 | props: PropType; 273 | 274 | 275 | /** 276 | * If the component is requesting an image or not 277 | * @type {Boolean} 278 | */ 279 | isRequesting: boolean = false; 280 | 281 | 282 | /** 283 | * Progress loaded and total amount of bytes 284 | * @type {Object} 285 | */ 286 | progress: { loaded: number, total: number } = { 287 | loaded: 0, 288 | total: 1, 289 | }; 290 | 291 | 292 | /** 293 | * The progress of amount lodaded 294 | * @return {Number} The percentaje loaded 295 | */ 296 | get amountLoaded(): number { 297 | return (this.progress.loaded * 100) / this.progress.total; 298 | } 299 | 300 | 301 | /** 302 | * Check if the component is in the viewport 303 | * @return {Boolean} If the component is in viewport 304 | */ 305 | get isInViewport(): boolean { 306 | // get element position in viewport 307 | const rect: RectType = this.element.getBoundingClientRect(); 308 | // get viewport height and width 309 | const viewportHeight: number = (window.innerHeight || document.documentElement.clientHeight); 310 | const viewportWidth: number = (window.innerWidth || document.documentElement.clientWidth); 311 | // check if the element is in the viewport (or near to them) 312 | return ( 313 | rect.bottom >= (0 - this.props.offset) && 314 | rect.right >= (0 - this.props.offset) && 315 | rect.top < (viewportHeight + this.props.offset) && 316 | rect.left < (viewportWidth + this.props.offset) 317 | ); 318 | } 319 | 320 | 321 | /** 322 | * Get the images props without the component own props 323 | * @return {Object} The filtered props 324 | */ 325 | get imgProps(): Object { 326 | const ownProps = [ 327 | 'onLayout', 328 | 'onError', 329 | 'onLoad', 330 | 'onLoadEnd', 331 | 'onLoadStart', 332 | 'onAbort', 333 | 'onProgress', 334 | 'resizeMode', 335 | 'source', 336 | 'defaultSource', 337 | 'offset', 338 | 'minLoaded', 339 | ]; 340 | 341 | return Object 342 | .keys(this.props) 343 | .filter((propName: string): boolean => ownProps.indexOf(propName) === -1) 344 | .reduce( 345 | (props: PropType, propName: string): PropType => ({ 346 | ...props, 347 | [propName]: this.props[propName], 348 | }), 349 | {}, 350 | ); 351 | } 352 | 353 | 354 | /** 355 | * Component renderer method 356 | * @return {Object} The image JSX element 357 | */ 358 | render(): Object { 359 | return ( 360 | 365 | ); 366 | } 367 | } 368 | 369 | export default Image; 370 | -------------------------------------------------------------------------------- /source/read-blob-file.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Read Blob file as a base64 string 3 | * @param {Blob} file The Blob file instance to read 4 | * @return {Promise} A reader promise 5 | */ 6 | function readBlobFile(file: Blob): Promise { 7 | function promiseHandler(resolve: Function, reject: Function): FileReader { 8 | const reader: FileReader = new FileReader(); 9 | reader.readAsDataURL(file); 10 | reader.onloadend = function onReadEnd(): void { 11 | if (reader.error) return reject(reader.error); 12 | return resolve(reader.result); 13 | }; 14 | return reader; 15 | } 16 | 17 | return new Promise(promiseHandler); 18 | } 19 | 20 | export default readBlobFile; 21 | -------------------------------------------------------------------------------- /source/spinner.js: -------------------------------------------------------------------------------- 1 | export default 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nNTBweCcgaGVpZ2h0PSc1MHB4JyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWlkWU1pZCIgY2xhc3M9InVpbC1kZWZhdWx0Ij48cmVjdCB4PSIwIiB5PSIwIiB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0ibm9uZSIgY2xhc3M9ImJrIj48L3JlY3Q+PHJlY3QgIHg9JzQ2JyB5PSc0MCcgd2lkdGg9JzgnIGhlaWdodD0nMjAnIHJ4PSc1JyByeT0nNScgZmlsbD0nI2MwYzBjMCcgdHJhbnNmb3JtPSdyb3RhdGUoMCA1MCA1MCkgdHJhbnNsYXRlKDAgLTMwKSc+ICA8YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSdvcGFjaXR5JyBmcm9tPScxJyB0bz0nMCcgZHVyPScwLjdzJyBiZWdpbj0nMHMnIHJlcGVhdENvdW50PSdpbmRlZmluaXRlJy8+PC9yZWN0PjxyZWN0ICB4PSc0NicgeT0nNDAnIHdpZHRoPSc4JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNjMGMwYzAnIHRyYW5zZm9ybT0ncm90YXRlKDQ1IDUwIDUwKSB0cmFuc2xhdGUoMCAtMzApJz4gIDxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9J29wYWNpdHknIGZyb209JzEnIHRvPScwJyBkdXI9JzAuN3MnIGJlZ2luPScwLjA4NzVzJyByZXBlYXRDb3VudD0naW5kZWZpbml0ZScvPjwvcmVjdD48cmVjdCAgeD0nNDYnIHk9JzQwJyB3aWR0aD0nOCcgaGVpZ2h0PScyMCcgcng9JzUnIHJ5PSc1JyBmaWxsPScjYzBjMGMwJyB0cmFuc2Zvcm09J3JvdGF0ZSg5MCA1MCA1MCkgdHJhbnNsYXRlKDAgLTMwKSc+ICA8YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSdvcGFjaXR5JyBmcm9tPScxJyB0bz0nMCcgZHVyPScwLjdzJyBiZWdpbj0nMC4xNzVzJyByZXBlYXRDb3VudD0naW5kZWZpbml0ZScvPjwvcmVjdD48cmVjdCAgeD0nNDYnIHk9JzQwJyB3aWR0aD0nOCcgaGVpZ2h0PScyMCcgcng9JzUnIHJ5PSc1JyBmaWxsPScjYzBjMGMwJyB0cmFuc2Zvcm09J3JvdGF0ZSgxMzUgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPiAgPGFuaW1hdGUgYXR0cmlidXRlTmFtZT0nb3BhY2l0eScgZnJvbT0nMScgdG89JzAnIGR1cj0nMC43cycgYmVnaW49JzAuMjYyNDk5OTk5OTk5OTk5OTZzJyByZXBlYXRDb3VudD0naW5kZWZpbml0ZScvPjwvcmVjdD48cmVjdCAgeD0nNDYnIHk9JzQwJyB3aWR0aD0nOCcgaGVpZ2h0PScyMCcgcng9JzUnIHJ5PSc1JyBmaWxsPScjYzBjMGMwJyB0cmFuc2Zvcm09J3JvdGF0ZSgxODAgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPiAgPGFuaW1hdGUgYXR0cmlidXRlTmFtZT0nb3BhY2l0eScgZnJvbT0nMScgdG89JzAnIGR1cj0nMC43cycgYmVnaW49JzAuMzVzJyByZXBlYXRDb3VudD0naW5kZWZpbml0ZScvPjwvcmVjdD48cmVjdCAgeD0nNDYnIHk9JzQwJyB3aWR0aD0nOCcgaGVpZ2h0PScyMCcgcng9JzUnIHJ5PSc1JyBmaWxsPScjYzBjMGMwJyB0cmFuc2Zvcm09J3JvdGF0ZSgyMjUgNTAgNTApIHRyYW5zbGF0ZSgwIC0zMCknPiAgPGFuaW1hdGUgYXR0cmlidXRlTmFtZT0nb3BhY2l0eScgZnJvbT0nMScgdG89JzAnIGR1cj0nMC43cycgYmVnaW49JzAuNDM3NXMnIHJlcGVhdENvdW50PSdpbmRlZmluaXRlJy8+PC9yZWN0PjxyZWN0ICB4PSc0NicgeT0nNDAnIHdpZHRoPSc4JyBoZWlnaHQ9JzIwJyByeD0nNScgcnk9JzUnIGZpbGw9JyNjMGMwYzAnIHRyYW5zZm9ybT0ncm90YXRlKDI3MCA1MCA1MCkgdHJhbnNsYXRlKDAgLTMwKSc+ICA8YW5pbWF0ZSBhdHRyaWJ1dGVOYW1lPSdvcGFjaXR5JyBmcm9tPScxJyB0bz0nMCcgZHVyPScwLjdzJyBiZWdpbj0nMC41MjQ5OTk5OTk5OTk5OTk5cycgcmVwZWF0Q291bnQ9J2luZGVmaW5pdGUnLz48L3JlY3Q+PHJlY3QgIHg9JzQ2JyB5PSc0MCcgd2lkdGg9JzgnIGhlaWdodD0nMjAnIHJ4PSc1JyByeT0nNScgZmlsbD0nI2MwYzBjMCcgdHJhbnNmb3JtPSdyb3RhdGUoMzE1IDUwIDUwKSB0cmFuc2xhdGUoMCAtMzApJz4gIDxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9J29wYWNpdHknIGZyb209JzEnIHRvPScwJyBkdXI9JzAuN3MnIGJlZ2luPScwLjYxMjQ5OTk5OTk5OTk5OTlzJyByZXBlYXRDb3VudD0naW5kZWZpbml0ZScvPjwvcmVjdD48L3N2Zz4='; 2 | --------------------------------------------------------------------------------