├── .all-contributorsrc ├── .babelrc ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .npmrc ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── index.d.ts ├── introduction.md ├── jest.config.js ├── other ├── CODE_OF_CONDUCT.md ├── MAINTAINING.md ├── USERS.md ├── idealimage-vs-img.md ├── idealimage-vs-img │ ├── ii-androind-filmstrip.png │ ├── ii-androind-screen.jpg │ ├── ii-androind-waterfall.png │ ├── ii-ios-filmstrip.png │ ├── ii-ios-screen.jpg │ ├── ii-ios-waterfall.png │ ├── img-android-filmstrip.png │ ├── img-android-screen.jpg │ ├── img-android-waterfall.png │ ├── img-ios-filmstrip.png │ ├── img-ios-screen.jpg │ └── img-ios-waterfall.png ├── images │ ├── andre-spieker-238-unsplash.jpg │ ├── andre-spieker-238-unsplash.webp │ ├── jairo-alzate-45522-unsplash.jpg │ ├── jairo-alzate-45522-unsplash.webp │ ├── marvin-meyer-188676-unsplash.jpg │ ├── marvin-meyer-188676-unsplash.webp │ ├── nidhin-mundackal-281287-unsplash.jpg │ ├── nidhin-mundackal-281287-unsplash.webp │ ├── vincent-van-zalinge-408523-unsplash.jpg │ └── vincent-van-zalinge-408523-unsplash.webp ├── introduction │ ├── error-404.png │ ├── error.png │ ├── filmstrip-img-android-0.1.png │ ├── filmstrip-img-android.png │ ├── filmstrip-img-ios-0.1.png │ ├── filmstrip-img.png │ ├── filmstrip-lqip-react.png │ ├── filmstrip-lqip.png │ ├── load-size.png │ ├── load.png │ ├── loaded.png │ ├── loading.png │ ├── noicon.png │ ├── offline.png │ ├── screen-slow3g-chrome.jpg │ ├── screen-slow3g-safari-1sec.jpg │ ├── screen.jpg │ ├── waterfall-img.png │ ├── waterfall-lazy-load.png │ ├── waterfall-slow3g-chrome.png │ └── waterfall-slow3g-safari-1sec.png └── manual-releases.md ├── package.json ├── prettier.config.js ├── renovate.json ├── src ├── __tests__ │ ├── .eslintrc │ ├── IdealImage.js │ ├── IdealImageWithDefaults.js │ ├── __snapshots__ │ │ ├── IdealImage.js.snap │ │ ├── icon.js.snap │ │ └── idealImageWithDefaults.js.snap │ ├── basic.test.tsx │ ├── composeStyle.js │ ├── helpers.js │ ├── helpers.node.js │ └── icon.js ├── components │ ├── Icon │ │ ├── Download.js │ │ ├── Loading.js │ │ ├── Offline.js │ │ ├── Warning.js │ │ └── index.js │ ├── IdealImage │ │ └── index.js │ ├── IdealImageWithDefaults │ │ └── index.js │ ├── Media │ │ └── index.js │ ├── MediaWithDefaults │ │ ├── README.md │ │ └── index.js │ ├── composeStyle.js │ ├── constants.js │ ├── helpers.js │ ├── icons.js │ ├── loaders.js │ ├── theme.js │ ├── theme.module.css │ └── unfetch.js └── index.js ├── styleguide.config.js └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "react-ideal-image", 3 | "projectOwner": "stereobooster", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "imageSize": 100, 8 | "commit": false, 9 | "contributors": [ 10 | { 11 | "login": "stereobooster", 12 | "name": "stereobooster", 13 | "avatar_url": "https://avatars3.githubusercontent.com/u/179534?s=460&v=4", 14 | "profile": "https://github.com/stereobooster", 15 | "contributions": [ 16 | "code", 17 | "doc", 18 | "infra", 19 | "test" 20 | ] 21 | }, 22 | { 23 | "login": "sompylasar", 24 | "name": "Ivan Babak", 25 | "avatar_url": "https://avatars1.githubusercontent.com/u/498274?s=460&v=4", 26 | "profile": "https://github.com/sompylasar", 27 | "contributions": [ 28 | "doc" 29 | ] 30 | }, 31 | { 32 | "login": "palerdot", 33 | "name": "Arun Kumar", 34 | "avatar_url": "https://avatars1.githubusercontent.com/u/4299398?s=460&v=4", 35 | "profile": "https://github.com/palerdot", 36 | "contributions": [ 37 | "doc" 38 | ] 39 | }, 40 | { 41 | "login": "hipstersmoothie", 42 | "name": "Andrew Lisowski", 43 | "avatar_url": "https://avatars3.githubusercontent.com/u/1192452?v=4", 44 | "profile": "http://hipstersmoothie.com", 45 | "contributions": [ 46 | "code" 47 | ] 48 | }, 49 | { 50 | "login": "tvthatsme", 51 | "name": "Timothy Vernon", 52 | "avatar_url": "https://avatars1.githubusercontent.com/u/3386714?v=4", 53 | "profile": "https://github.com/tvthatsme", 54 | "contributions": [ 55 | "test" 56 | ] 57 | }, 58 | { 59 | "login": "vs1682", 60 | "name": "vishalShinde", 61 | "avatar_url": "https://avatars0.githubusercontent.com/u/5151881?v=4", 62 | "profile": "http://vs1682.github.io", 63 | "contributions": [ 64 | "doc" 65 | ] 66 | }, 67 | { 68 | "login": "EvgeniyKumachev", 69 | "name": "Evgeniy Kumachev", 70 | "avatar_url": "https://avatars3.githubusercontent.com/u/5207796?v=4", 71 | "profile": "https://github.com/EvgeniyKumachev", 72 | "contributions": [ 73 | "doc" 74 | ] 75 | }, 76 | { 77 | "login": "Tawe", 78 | "name": "John Munn", 79 | "avatar_url": "https://avatars0.githubusercontent.com/u/2087056?v=4", 80 | "profile": "https://github.com/Tawe", 81 | "contributions": [ 82 | "code" 83 | ] 84 | } 85 | ], 86 | "repoType": "github" 87 | } 88 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react"], 3 | "plugins": ["@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js text eol=lf 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | * `react-ideal-image` version: 15 | * `node` version: 16 | * `npm` (or `yarn`) version: 17 | 18 | Relevant code or config 19 | 20 | ```javascript 21 | ``` 22 | 23 | What you did: 24 | 25 | What happened: 26 | 27 | 28 | 29 | Reproduction repository: 30 | 31 | 35 | 36 | Problem description: 37 | 38 | Suggested solution: 39 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | **What**: 20 | 21 | 22 | 23 | **Why**: 24 | 25 | 26 | 27 | **How**: 28 | 29 | 30 | 31 | **Checklist**: 32 | 33 | 34 | 35 | 36 | 37 | * [ ] Documentation 38 | * [ ] Tests 39 | * [ ] Ready to be merged 40 | * [ ] Added myself to contributors table 41 | 42 | 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | styleguide 5 | .opt-in 6 | .opt-out 7 | .DS_Store 8 | .eslintcache 9 | 10 | # these cause more harm than good 11 | # when working with contributors 12 | package-lock.json 13 | yarn.lock 14 | yarn-error.log 15 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=http://registry.npmjs.org/ 2 | package-lock=false 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - "~/.npm" 6 | - "node_modules" 7 | notifications: 8 | email: false 9 | node_js: 10 | - '10' 11 | install: 12 | - npm install -g npm 13 | - npm install 14 | script: npm run validate 15 | after_success: kcd-scripts travis-after-success 16 | branches: 17 | only: master 18 | env: 19 | global: 20 | secure: hG6olL1YgWWkvDW+oVQ4BNIape5GsI9uOqPG6WAP2t1LjG5McqFCP6N0HJLfGI/iiTjbhDhBi69lFXyBzWGEM8Smvmnh1iekRQ+Vrt1L9Q+aXEoRm97LtAbIlKNat4QP9ZW1gaNAPgWrzE0mlPxtOYUNjpR8+T6Ivt2Q04iT2cP7kqgC6CmGVVEk3hOAQqWXkkzUUNifcRs9IdeJrBQzWBE2s1Pw+e2GSvb75fW0hXT9AzfFbl5Q1Nw4R2Mai8uDcqaTEROYxdipz1NJTxvEzix71a2vNEXDGMocnLJldd6SCIJ4SzTwq2qcTnlH0PizvK7ym9yRqDbUudxq09/hqf/q8UzjbWMxImB6Aa7E6KH0zfguJUb0jlOd5mkGnJBE67G+yYrgfCutmGIho4UzWgwpu5PJsENzy1xUN/syKtgCc7Nu1l4tag+3U1vo45GcN+CcY05gKWj7rK9RkXTwBvloXJXhocXfVP3hoQq+WDqFyfEdg74U97Rn6bJ7kluC14m7IAoKRwLjJpLx8BRGCVhMwvPlCdW2ZkBkLgkQVHuNXXZ41sSVxq4PEI+04CncEvmrrWbyBJSApq998hKVkVLwtl1RtVxBN5jJJk59JuDjpq4WTN5GIDNs7x6/oyuGFAQzPJ4/tKxXPA6Jls+TzruY837oG5hfhk2vUY1NzHg= 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | The changelog is automatically updated using [semantic-release](https://github.com/semantic-release/semantic-release). 4 | You can see it on the [releases page](../../releases). 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | **Working on your first Pull Request?** You can learn how from this _free_ series 6 | [How to Contribute to an Open Source Project on GitHub][egghead] 7 | 8 | ## Project setup 9 | 10 | 1. Fork and clone the repo 11 | 2. Run `npm run setup -s` to install dependencies and run validation 12 | 3. Create a branch for your PR with `git checkout -b pr/your-branch-name` 13 | 14 | > Tip: Keep your `master` branch pointing at the original repository and make 15 | > pull requests from branches on your fork. To do this, run: 16 | > 17 | > ``` 18 | > git remote add upstream https://github.com/stereobooster/react-ideal-image.git 19 | > git fetch upstream 20 | > git branch --set-upstream-to=upstream/master master 21 | > ``` 22 | > 23 | > This will add the original repository as a "remote" called "upstream," 24 | > Then fetch the git information from that remote, then set your local `master` 25 | > branch to use the upstream master branch whenever you run `git pull`. 26 | > Then you can make all of your pull request branches based on this `master` 27 | > branch. Whenever you want to update your version of `master`, do a regular 28 | > `git pull`. 29 | 30 | ## Add yourself as a contributor 31 | 32 | This project follows the [all contributors][all-contributors] specification. 33 | To add yourself to the table of contributors on the `README.md`, please use the 34 | automated script as part of your PR: 35 | 36 | ```console 37 | npm run add-contributor 38 | ``` 39 | 40 | Follow the prompt and commit `.all-contributorsrc` and `README.md` in the PR. 41 | If you've already added yourself to the list and are making 42 | a new type of contribution, you can run it again and select the added 43 | contribution type. 44 | 45 | ## Committing and Pushing changes 46 | 47 | Please make sure to run the tests before you commit your changes. You can run 48 | `npm run test:update` which will update any snapshots that need updating. 49 | Make sure to include those changes (if they exist) in your commit. 50 | 51 | ### opt into git hooks 52 | 53 | There are git hooks set up with this project that are automatically installed 54 | when you install dependencies. They're really handy, but are turned off by 55 | default (so as to not hinder new contributors). You can opt into these by 56 | creating a file called `.opt-in` at the root of the project and putting this 57 | inside: 58 | 59 | ``` 60 | pre-commit 61 | ``` 62 | 63 | ## Help needed 64 | 65 | Please checkout the [the open issues][issues] 66 | 67 | Also, please watch the repo and respond to questions/bug reports/feature 68 | requests! Thanks! 69 | 70 | [egghead]: https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github 71 | [all-contributors]: https://github.com/stereobooster/all-contributors 72 | [issues]: https://github.com/stereobooster/react-ideal-image/issues 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2017 stereobooster 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://vshymanskyy.github.io/StandWithUkraine) 2 | 3 |
4 |

react-ideal-image

5 | 6 |

Adaptive image component

7 |
8 | 9 |
10 | 11 | [![Build Status][build-badge]][build] 12 | [![Code Coverage][coverage-badge]][coverage] 13 | [![version][version-badge]][package] 14 | [![downloads][downloads-badge]][npmtrends] 15 | [![MIT License][license-badge]][license] 16 | 17 | [![All Contributors](https://img.shields.io/badge/all_contributors-8-orange.svg?style=flat-square)](#contributors) 18 | [![PRs Welcome][prs-badge]][prs] 19 | [![Code of Conduct][coc-badge]][coc] 20 | 21 | [![Watch on GitHub][github-watch-badge]][github-watch] 22 | [![Star on GitHub][github-star-badge]][github-star] 23 | [![Tweet][twitter-badge]][twitter] 24 | 25 | ## The problem 26 | 27 | I need React component to asynchronously load images, which will adapt based on network, which will allow a user to control, which image to load. 28 | 29 | ## This solution 30 | 31 | Read the [introduction](introduction.md). 32 | 33 | ## Table of Contents 34 | 35 | 36 | 37 | 38 | - [Installation](#installation) 39 | - [Usage](#usage) 40 | - [Props](#props) 41 | - [getIcon](#geticon) 42 | - [getMessage](#getmessage) 43 | - [getUrl](#geturl) 44 | - [height](#height) 45 | - [icons](#icons) 46 | - [loader](#loader) 47 | - [placeholder](#placeholder) 48 | - [shouldAutoDownload](#shouldautodownload) 49 | - [srcSet](#srcset) 50 | - [theme](#theme) 51 | - [threshold](#threshold) 52 | - [width](#width) 53 | - [Other Solutions](#other-solutions) 54 | - [Contributors](#contributors) 55 | - [LICENSE](#license) 56 | 57 | 58 | 59 | ## Installation 60 | 61 | This module is distributed via [npm][npm] which is bundled with [node][node] and 62 | should be installed as one of your project's `dependencies`: 63 | 64 | ``` 65 | npm install react-ideal-image --save 66 | ``` 67 | 68 | > This package also depends on `react`, `prop-types`, and `react-waypoint`. 69 | > Please make sure you have those installed as well. 70 | 71 | ## Usage 72 | 73 | Example for create-react-app (you need v2 for macros) based project 74 | 75 | ```js 76 | import React from 'react' 77 | import lqip from 'lqip.macro' 78 | import IdealImage from 'react-ideal-image' 79 | 80 | import image from './images/doggo.jpg' 81 | const lqip = lqip('./images/doggo.jpg') 82 | 83 | const App = () => ( 84 | 91 | ) 92 | ``` 93 | 94 | ## Props 95 | 96 | This is the list of props that you need to pass to the component. 97 | 98 | ### getIcon 99 | 100 | > `function(state: object)` | optional, default icon is provided based on state object 101 | 102 | This function decides what icon to show based on the current state of the component. 103 | 104 | ### getMessage 105 | 106 | > `function(icon: string, state: object)` | optional, default message is provided based on the icon and state object. 107 | 108 | This function decides what message to show based on the icon (returned from getIcon prop) and the current state of the component. 109 | 110 | ### getUrl 111 | 112 | > `function({})` | optional, no useful default 113 | 114 | This function is called as soon as the component enters the viewport and is used to generate urls based on width and format if `props.srcSet` doesn't provide src field. 115 | 116 | ### height 117 | 118 | > `number` | required 119 | 120 | The Height of the image in px. 121 | 122 | ### icons 123 | 124 | > `object` | required 125 | 126 | This provides a map of the icons. By default, the component uses icons from material design, implemented as React components with the SVG element. You can customize icons 127 | 128 | ```js 129 | const icons = { 130 | load: DownloadIcon, 131 | //... 132 | } 133 | ``` 134 | 135 | ### loader 136 | 137 | > `string` | optional, defaults to 'xhr' 138 | 139 | This prop takes one of the 2 options, `xhr` and `image`. Read more about it [here](https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#cancel-download). 140 | 141 | ### placeholder 142 | 143 | > `object` | required 144 | 145 | This takes one of the 2 objects 146 | 147 | ```js 148 | // To add a solid color placeholder 149 | { 150 | color: '' 151 | } 152 | ``` 153 | 154 | or 155 | 156 | ```js 157 | /** 158 | * To add a low quality image 159 | * [Low Quality Image Placeholder](https://github.com/zouhir/lqip) 160 | * [SVG-Based Image Placeholder](https://github.com/technopagan/sqip) 161 | * base64 encoded image of low quality 162 | */ 163 | { 164 | lqip: '' 165 | } 166 | ``` 167 | 168 | Read more about it [here](https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#lqip). 169 | 170 | ### shouldAutoDownload 171 | 172 | > `function({})` | optional, default function is provided which decides based on the device network. 173 | 174 | This function decides if image should be downloaded automatically. The default function returns `false` for a `2g` network, 175 | for a `3g` network it decides based on `props.threshold` and for a `4g` network it returns `true` by default. 176 | 177 | ### srcSet 178 | 179 | > `array[srcType: object]` | required 180 | 181 | This provides an array of sources of different format and size of the image. Read more about it [here](https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#srcset). 182 | The `srcType` has below structure 183 | 184 | ```js 185 | srcType = { 186 | width: number, // required 187 | src: string, 188 | size: number, 189 | format: string, // one of the 'jpeg' or 'webp' 190 | } 191 | ``` 192 | 193 | ### theme 194 | 195 | > `object` | required 196 | 197 | This provides a theme to the component. By default, the component uses inline styles, but it is also possible to use CSS modules and override all styles. 198 | 199 | ```js 200 | const theme = { 201 | placeholder: { 202 | backgroundSize: 'cover', 203 | backgroundRepeat: 'no-repeat', 204 | position: 'relative', 205 | }, 206 | // ... 207 | } 208 | ``` 209 | 210 | ### threshold 211 | 212 | > `number` | optional 213 | 214 | Tells how much to wait in milliseconds until consider the download to be slow. 215 | 216 | ### width 217 | 218 | > `number` | required 219 | 220 | Width of the image in px. 221 | 222 | ## Other Solutions 223 | 224 | - [react-progressive-image](https://github.com/FormidableLabs/react-progressive-image) 225 | - [react-lazyload](https://github.com/jasonslyvia/react-lazyload) 226 | - [react-lazy-image](https://github.com/sergiodxa/react-lazy-image) 227 | - [react-image](https://github.com/mbrevda/react-image) 228 | - [react-lazy-load](https://github.com/loktar00/react-lazy-load) 229 | - [react-graceful-image](https://github.com/linasmnew/react-graceful-image) 230 | - [react-worker-image](https://github.com/nitish24p/react-worker-image) 231 | - [lazy-image](https://github.com/notwaldorf/lazy-image) 232 | - [react-simple-image](https://github.com/bitjourney/react-simple-image) 233 | - [react-power-picture](https://github.com/tvthatsme/react-power-picture) 234 | - [react-shimmer](https://github.com/gokcan/react-shimmer) 235 | - [gatsby-image](https://www.gatsbyjs.org/packages/gatsby-image/) 236 | - [react-async-elements ``](https://github.com/palmerhq/react-async-elements#img) 237 | 238 | ## Contributors 239 | 240 | Thanks goes to these people ([emoji key][emojis]): 241 | 242 | 243 | 244 | | [
stereobooster](https://github.com/stereobooster)
[💻](https://github.com/stereobooster/react-ideal-image/commits?author=stereobooster "Code") [📖](https://github.com/stereobooster/react-ideal-image/commits?author=stereobooster "Documentation") [🚇](#infra-stereobooster "Infrastructure (Hosting, Build-Tools, etc)") [⚠️](https://github.com/stereobooster/react-ideal-image/commits?author=stereobooster "Tests") | [
Ivan Babak](https://github.com/sompylasar)
[📖](https://github.com/stereobooster/react-ideal-image/commits?author=sompylasar "Documentation") | [
Arun Kumar](https://github.com/palerdot)
[📖](https://github.com/stereobooster/react-ideal-image/commits?author=palerdot "Documentation") | [
Andrew Lisowski](http://hipstersmoothie.com)
[💻](https://github.com/stereobooster/react-ideal-image/commits?author=hipstersmoothie "Code") | [
Timothy Vernon](https://github.com/tvthatsme)
[⚠️](https://github.com/stereobooster/react-ideal-image/commits?author=tvthatsme "Tests") | [
vishalShinde](http://vs1682.github.io)
[📖](https://github.com/stereobooster/react-ideal-image/commits?author=vs1682 "Documentation") | [
Evgeniy Kumachev](https://github.com/EvgeniyKumachev)
[📖](https://github.com/stereobooster/react-ideal-image/commits?author=EvgeniyKumachev "Documentation") | 245 | | :---: | :---: | :---: | :---: | :---: | :---: | :---: | 246 | | [
John Munn](https://github.com/Tawe)
[💻](https://github.com/stereobooster/react-ideal-image/commits?author=Tawe "Code") | 247 | 248 | 249 | 250 | This project follows the [all-contributors][all-contributors] specification. 251 | Contributions of any kind welcome! 252 | 253 | ## LICENSE 254 | 255 | Code - MIT 256 | 257 | Icons - [Apache License 2.0](https://github.com/google/material-design-icons/blob/master/LICENSE) 258 | 259 | [npm]: https://www.npmjs.com/ 260 | [node]: https://nodejs.org 261 | [build-badge]: https://img.shields.io/travis/stereobooster/react-ideal-image.svg?style=flat-square 262 | [build]: https://travis-ci.org/stereobooster/react-ideal-image 263 | [coverage-badge]: https://img.shields.io/codecov/c/github/stereobooster/react-ideal-image.svg?style=flat-square 264 | [coverage]: https://codecov.io/github/stereobooster/react-ideal-image 265 | [version-badge]: https://img.shields.io/npm/v/react-ideal-image.svg?style=flat-square 266 | [package]: https://www.npmjs.com/package/react-ideal-image 267 | [downloads-badge]: https://img.shields.io/npm/dm/react-ideal-image.svg?style=flat-square 268 | [npmtrends]: http://www.npmtrends.com/react-ideal-image 269 | [license-badge]: https://img.shields.io/npm/l/react-ideal-image.svg?style=flat-square 270 | [license]: https://github.com/stereobooster/react-ideal-image/blob/master/LICENSE 271 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 272 | [prs]: http://makeapullrequest.com 273 | [donate-badge]: https://img.shields.io/badge/$-support-green.svg?style=flat-square 274 | [coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square 275 | [coc]: https://github.com/stereobooster/react-ideal-image/blob/master/other/CODE_OF_CONDUCT.md 276 | [github-watch-badge]: https://img.shields.io/github/watchers/stereobooster/react-ideal-image.svg?style=social 277 | [github-watch]: https://github.com/stereobooster/react-ideal-image/watchers 278 | [github-star-badge]: https://img.shields.io/github/stars/stereobooster/react-ideal-image.svg?style=social 279 | [github-star]: https://github.com/stereobooster/react-ideal-image/stargazers 280 | [twitter]: https://twitter.com/intent/tweet?text=Check%20out%20react-ideal-image%20by%20%40stereobooster%20https%3A%2F%2Fgithub.com%2Fstereobooster%2Freact-ideal-image%20%F0%9F%91%8D 281 | [twitter-badge]: https://img.shields.io/twitter/url/https/github.com/stereobooster/react-ideal-image.svg?style=social 282 | [emojis]: https://github.com/kentcdodds/all-contributors#emoji-key 283 | [all-contributors]: https://github.com/kentcdodds/all-contributors 284 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import {Component, ComponentType, ComponentClass, CSSProperties} from 'react' 2 | 3 | export type LoadingState = 'initial' | 'loading' | 'loaded' | 'error' 4 | 5 | export type IconKey = 6 | | 'load' 7 | | 'loading' 8 | | 'loaded' 9 | | 'error' 10 | | 'noicon' 11 | | 'offline' 12 | 13 | export interface SrcType { 14 | width: number 15 | src?: string 16 | size?: number 17 | format?: 'webp' | 'jpeg' 18 | } 19 | 20 | type ThemeKey = 'placeholder' | 'img' | 'icon' | 'noscript' 21 | 22 | export interface ImageProps { 23 | /** 24 | * This function decides what icon to show based on the current state of the component. 25 | */ 26 | getIcon?: (state: LoadingState) => IconKey 27 | /** 28 | * This function decides what message to show based on the icon (returned from getIcon prop) and 29 | * the current state of the component. 30 | */ 31 | getMessage?: (icon: IconKey, state: LoadingState) => string 32 | /** 33 | * This function is called as soon as the component enters the viewport and is used to generate urls 34 | * based on width and format if props.srcSet doesn't provide src field. 35 | */ 36 | getUrl?: (srcType: SrcType) => string 37 | /** 38 | * The Height of the image in px. 39 | */ 40 | height: number 41 | /** 42 | * This provides a map of the icons. By default, the component uses icons from material design, 43 | * implemented as React components with the SVG element. You can customize icons 44 | */ 45 | icons: Partial> 46 | /** 47 | * This prop takes one of the 2 options, xhr and image. 48 | * Read more about it: 49 | * https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#cancel-download 50 | */ 51 | loader?: 'xhr' | 'image' 52 | /** 53 | * https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#lqip 54 | */ 55 | placeholder: {color: string} | {lqip: string} 56 | /** 57 | * This function decides if image should be downloaded automatically. The default function 58 | * returns false for a 2g network, for a 3g network it decides based on props.threshold 59 | * and for a 4g network it returns true by default. 60 | */ 61 | shouldAutoDownload?: ( 62 | options: { 63 | connection?: 'slow-2g' | '2g' | '3g' | '4g' 64 | size?: number 65 | threshold?: number 66 | possiblySlowNetwork?: boolean 67 | }, 68 | ) => boolean 69 | /** 70 | * This provides an array of sources of different format and size of the image. 71 | * Read more about it: 72 | * https://github.com/stereobooster/react-ideal-image/blob/master/introduction.md#srcset 73 | */ 74 | srcSet: SrcType[] 75 | /** 76 | * This provides a theme to the component. By default, the component uses inline styles, 77 | * but it is also possible to use CSS modules and override all styles. 78 | */ 79 | theme?: Partial> 80 | /** 81 | * Tells how much to wait in milliseconds until consider the download to be slow. 82 | */ 83 | threshold?: number 84 | /** 85 | * Width of the image in px. 86 | */ 87 | width: number 88 | } 89 | 90 | type IdealImageComponent = ComponentClass 91 | 92 | declare const IdealImage: IdealImageComponent 93 | export default IdealImage 94 | -------------------------------------------------------------------------------- /introduction.md: -------------------------------------------------------------------------------- 1 | # An Almost Ideal React Image Component 2 | 3 | TL;DR. This started as an exercise - how to build ideal React image component. The focus was more on UX and browser capabilities, rather than React code. I created react component and published it to npm, but it has no tests and not battle tested in the wild, use it at your own risk. 4 | 5 | [Online example](https://stereobooster.github.io/react-ideal-image-experiments/) | [HN discussion](https://news.ycombinator.com/item?id=17210378) | [Guide To Async Components](https://github.com/stereobooster/guide-to-async-components) | [IdealImage vs img](https://github.com/stereobooster/react-ideal-image/blob/master/other/idealimage-vs-img.md) 6 | 7 | ## Lazy loading 8 | 9 | This is a straightforward feature - do not load images which are outside of the screen. Do not need to reinvent a wheel, there is [react-waypoint](https://github.com/brigade/react-waypoint), to trigger actions in the component as soon as it appears on the screen (pseudo code): 10 | 11 | ```js 12 | this.setState({src})}> 13 | 14 | 15 | ``` 16 | 17 | 18 | 19 | 23 | 27 | 28 | 29 | 33 | 34 |
20 | Pic 1. Browser's `img` loads all 5 images on the page, but only 3 are visible 21 | 22 | 24 | Pic 3. Screenshot of the page 25 | 26 |
30 | Pic 2. "Lazy-load" loads only 3 visible images 31 | 32 |
35 | 36 | ## Placeholder 37 | 38 | As soon as you start to do lazy loading you will notice unpleasant content jumps as soon as images get loaded. This is bad for two reasons: UX - content jumps make user loose visual track, performance - content jumps are [browser redraws](https://developers.google.com/speed/docs/insights/browser-reflow). This is why we need a placeholder - a thing which will fill place until the image gets loaded. To do this we need to know image size upfront. AMP has same requirements for all blocks. Simplest placeholder(pseudo code): 39 | 40 | ```js 41 | load () { 42 | const img = new Image() 43 | img.onload = () => this.setState({loaded:true}) 44 | img.src = this.props.src 45 | } 46 | render() { 47 | if (!this.state.loaded) { 48 | return () 49 | } else { 50 | return () 51 |  } 52 | } 53 | ``` 54 | 55 | **Pic 4.** Load progress of images without dimension 56 | 57 | ![](other/introduction/filmstrip-img.png) 58 | 59 | ### LQIP 60 | 61 | Better, but not ideal. A user will see blank space until image load, this can be perceived as broken functionality - what if the image fails to load, what if it takes too long. Low-Quality Image Placeholder to the rescue. This technique is known since times of progressive JPEGs, later forgotten and reinvented by Facebook, Medium, and others. Also, we can use solid color placeholder or SQIP. Read more about placeholders [here](https://medium.freecodecamp.org/using-svg-as-placeholders-more-image-loading-techniques-bed1b810ab2c). To get LQIP you can use [sharp](https://github.com/lovell/sharp) 62 | 63 | ```js 64 | const getLqip = file => 65 | new Promise((resolve, reject) => { 66 | sharp(file) 67 | .resize(20) 68 | .toBuffer((err, data, info) => { 69 | if (err) return reject(err) 70 | const {format} = info 71 | return resolve(`data:image/${format};base64,${data.toString('base64')}`) 72 | }) 73 | }) 74 | 75 | const lqip = await getLqip('cute-dog.jpg') 76 | ``` 77 | 78 | Also check: [lqip](https://github.com/zouhir/lqip) or [lqip.macro](https://github.com/stereobooster/lqip.macro); [sqip](https://github.com/technopagan/sqip) or [sqip.macro](https://github.com/stereobooster/sqip.macro); 79 | 80 | Use LQIP like this (pseudo code): 81 | 82 | ```js 83 |
84 | 85 |
86 | ``` 87 | 88 | Or in the component: 89 | 90 | ```js 91 | 92 | ``` 93 | 94 | **Pic 5.** Load progress of images with LQIP, but without JS 95 | 96 | ![](other/introduction/filmstrip-lqip.png) 97 | 98 | **Pic 6.** Load progress of images with LQIP and with JS 99 | 100 | ![](other/introduction/filmstrip-lqip-react.png) 101 | 102 | ## Responsive 103 | 104 | ### Styles 105 | 106 | We are specifying exact width and height of the image and the placeholder. To make it responsive we need to add some CSS (pseudo code): 107 | 108 | ```js 109 | const img = { 110 | width: '100%', 111 | height: 'auto', 112 | maxWidth: '100%', 113 | } 114 | 115 | render() { 116 | if (this.state.loaded) { 117 | return () 118 | } else { 119 | return () 120 |  } 121 | } 122 | ``` 123 | 124 | **Pic 7.1** Load progress of `img` with "responsive style" in Android (0.1s interval) 125 | 126 | ![](other/introduction/filmstrip-img-android-0.1.png) 127 | 128 | **Pic 7.2.** Load progress of `img` with "responsive style" in iOS (0.5s interval) 129 | 130 | ![](other/introduction/filmstrip-img-ios-0.1.png) 131 | 132 | ### `srcSet` 133 | 134 | This feature is about reimplementing `srcSet` property of [responsive image](https://css-tricks.com/responsive-images-youre-just-changing-resolutions-use-srcSet/). It would be nice to use image based on the size of the screen, to minimize traffic for the images on small devices. 135 | 136 | To do this we will need: 137 | 138 | - Set of images resized for different devices. You can use [sharp](https://github.com/lovell/sharp) to resize images. 139 | - Data about how much space image takes on the screen. This is easy because we mount placeholder before the image, so the reference to the placeholder can be used to get dimensions 140 | - Some heuristic based on `screen.width`, `screen.height`, `window.devicePixelRatio`, `body.clientHeight` to guess maximum image size for given device 141 | - Would be nice to take into account `orientationchange` events, but will not do this for now. 142 | 143 | See exact implementation in the code (`guessMaxImageWidth`). Our component will look like this: 144 | 145 | ```js 146 | 156 | ``` 157 | 158 | Also possible to reimplement `sizes` param with [css-mediaquery](https://github.com/ericf/css-mediaquery), but this potentially can give more bugs than the actual value. 159 | 160 | ## Adaptive 161 | 162 | Most likely you haven't heard this term applied to the images, because I made it up. Adaptive image - an image which adapts to the environment, for example, if the browser supports WebP use it if the network is too slow stop auto download images if the browser is offline communicate to the user that download of the image is not possible at the moment. 163 | 164 | ### WebP 165 | 166 | To detect WebP support we can use this snippet copy-pasted from StackOverflow: 167 | 168 | ```js 169 | const detectWebpSupport = () => { 170 | if (ssr) return false 171 | const elem = document.createElement('canvas') 172 | if (elem.getContext && elem.getContext('2d')) { 173 | // was able or not to get WebP representation 174 | return elem.toDataURL('image/webp').indexOf('data:image/webp') === 0 175 | } else { 176 | // very old browser like IE 8, canvas not supported 177 | return false 178 | } 179 | } 180 | ``` 181 | 182 | Use component like this: 183 | 184 | ```js 185 | 192 | ``` 193 | 194 | ### Slow network 195 | 196 | If the network is slow it makes no sense to auto-download image (as soon as it appears on the screen), because it will take a long time to load even more time if the browser tries to download more than one image simultaneously. 197 | 198 | Instead, we can let the user decide if they want to download image or not. There should be an icon over placeholder, so the user can click it to start the download, and click again to cancel the download. As soon as the download starts there should be no icon, but if it takes too long some indicator of loading state should appear to inform the user that it is still working. 199 | 200 | | load | no icon | loading | 201 | | -------------------------------- | ---------------------------------- | ----------------------------------- | 202 | | ![](other/introduction/load.png) | ![](other/introduction/noicon.png) | ![](other/introduction/loading.png) | 203 | 204 | In Chrome it is pretty easy to detect the slow network with `navigator.connection.effectiveType`. If it is 'slow-2g', '2g', '3g' then the component will not auto-download images. 205 | 206 | | Component detected slow network and didn't try to load images ![](other/introduction/waterfall-slow3g-chrome.png) | Component switched to manual mode ![](other/introduction/screen-slow3g-chrome.jpg) | 207 | | :---------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- | 208 | 209 | 210 | For other browsers, we can try to guess if the download of the image takes too much time. How much time should be considered as too much is up-to developer, via `threshold` property (optional): 211 | 212 | ```js 213 | 214 | ``` 215 | 216 | If image takes too long to download and the load was initiated by "Lazy loading" feature then: 217 | 218 | - load process will be canceled 219 | - the component will show control, so the user can initiate the download of the image manually 220 | - the component will broadcast event `possibly slow network`, so other components would not even try load images and will be switched to "Manual mode" 221 | 222 | | Component tried to download images, but canceled load after 1 second ![](other/introduction/waterfall-slow3g-safari-1sec.png) | Component switched to manual mode ![](other/introduction/screen-slow3g-safari-1sec.jpg) | 223 | | :---------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------- | 224 | 225 | 226 | ### Cancel download 227 | 228 | In Chrome (and probably other browsers) you can asign empty string to `src` to cancel download, but this doesn't work in Mobile Safari: 229 | 230 | ```js 231 | const img = new Image() 232 | //... 233 | img.src = '' 234 | ``` 235 | 236 | Other way to do it is to use good old `XMLHttpRequest` which supports cancel: 237 | 238 | ```js 239 | const request = new XMLHttpRequest() 240 | //... 241 | request.abort() 242 | ``` 243 | 244 | Buuut: 245 | 246 | - if images are uncacheable this will not work - the browser will trigger another request for the image as soon as we insert an image in the DOM 247 | - if images are hosted on the different domain we will need to configure CORS properly 248 | 249 | This is why I chose to let developer decide which approach to use (default is `xhr`): 250 | 251 | ```js 252 | 253 | ``` 254 | 255 | It is also possible to use `fetch` with [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController), but it is supported only in Chrome 66+ at the moment. 256 | 257 | ## More UX 258 | 259 | ### Network error 260 | 261 | If image network request errored we need to show user message that browser failed to download the image. The user should be able to recover from the error (in case of temporal issue), by clicking on the image user can trigger repetitive load. 262 | 263 | ### 404 error 264 | 265 | 404 error is the special one. We use LQIP placeholder, which creates "impression" of content, but our component can outlive real image. We need clearly explain to the user that image doesn't exist anymore. 266 | 267 | ### Offline 268 | 269 | Because we are lazy loading images, it can happen that we have some unloaded images at the moment when the browser goes offline. We should not confuse users in this case with an error message, instead we should clearly identify that browser is offline and this is why browser cannot load images. 270 | 271 | | Network error | 404 error | Offline | 272 | | --------------------------------- | ------------------------------------- | ----------------------------------- | 273 | | ![](other/introduction/error.png) | ![](other/introduction/error-404.png) | ![](other/introduction/offline.png) | 274 | 275 | ## SSR or prerendering 276 | 277 | On the server, the component will be rendered with a placeholder (lqip) and without an icon. As soon as React application will boot, the component will decide if it needs to start download image or show download icon. 278 | 279 | ### Users with disabled JavaScript 280 | 281 | For users with disabled JavaScript or for search bots component will render the good old image in ` 129 | ) : null 130 | } 131 | 132 | render() { 133 | const props = this.props 134 | const {placeholder, theme} = props 135 | let background 136 | if (props.icon === loaded) { 137 | background = {} 138 | } else if (placeholder.lqip) { 139 | background = { 140 | backgroundImage: `url("${placeholder.lqip}")`, 141 | } 142 | } else { 143 | background = { 144 | backgroundColor: placeholder.color, 145 | } 146 | } 147 | return ( 148 |
159 | {this.renderImage(props)} 160 | {this.renderNoscript(props)} 161 | {this.renderIcon(props)} 162 |
163 | ) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/components/MediaWithDefaults/README.md: -------------------------------------------------------------------------------- 1 | All possible states of the component 2 | 3 | ```js 4 | const lqip = 5 | 'data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAA4DASIAAhEBAxEB/8QAFgABAQEAAAAAAAAAAAAAAAAAAAUG/8QAIRAAAQQDAAEFAAAAAAAAAAAAAQIDBREABAYhEjEyQVH/xAAUAQEAAAAAAAAAAAAAAAAAAAAE/8QAGBEBAAMBAAAAAAAAAAAAAAAAAQACIRH/2gAMAwEAAhEDEQA/AMJ2DG+7Dw0nz8gsx+uyhlxnWdLakOlfzpIF3aRf1WT5t96P5+N1ug9Tu7ZWS8q1gG6B8H2FDz+YxhjUrEOdZ//Z' 6 | 7 | const sqip = 8 | "data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4774 3024'%3e%3cfilter id='b'%3e%3cfeGaussianBlur stdDeviation='12' /%3e%3c/filter%3e%3cpath fill='%23515a57' d='M0 0h4774v3021H0z'/%3e%3cg filter='url(%23b)' transform='translate(9.3 9.3) scale(18.64844)' fill-opacity='.5'%3e%3cellipse fill='whitefef' rx='1' ry='1' transform='matrix(74.55002 60.89891 -21.7939 26.67923 151.8 104.4)'/%3e%3cellipse fill='black80c' cx='216' cy='49' rx='59' ry='59'/%3e%3cellipse fill='black60e' cx='22' cy='60' rx='46' ry='89'/%3e%3cellipse fill='%23ffebd5' cx='110' cy='66' rx='42' ry='28'/%3e%3cellipse fill='whiteff9' rx='1' ry='1' transform='rotate(33.3 -113.2 392.6) scale(42.337 17.49703)'/%3e%3cellipse fill='%23031f1e' rx='1' ry='1' transform='matrix(163.4651 -64.93326 6.77862 17.06471 111 16.4)'/%3e%3cpath fill='whitefea' d='M66 74l9 39 16-44z'/%3e%3cellipse fill='%23a28364' rx='1' ry='1' transform='rotate(-32.4 253.2 -179) scale(30.79511 43.65381)'/%3e%3cpath fill='%231a232c' d='M40 139l61-57 33 95z'/%3e%3cpath fill='%230a222b' d='M249.8 153.3l-48.1-48 32.5-32.6 48.1 48z'/%3e%3c/g%3e%3c/svg%3e" 9 | ; 10 | 11 | 12 | 15 | 25 | 28 | 38 | 39 | 40 | 41 | 51 | 52 | 62 | 63 | 64 | 65 | 75 | 76 | 86 | 87 | 88 |
13 | load 14 | 16 | 24 | 26 | noicon 27 | 29 | 37 |
loading 42 | 50 | offline 53 | 61 |
loaded 66 | 74 | error 77 | 85 |
89 | ``` 90 | -------------------------------------------------------------------------------- /src/components/MediaWithDefaults/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Media from '../Media' 3 | import icons from '../icons' 4 | import theme from '../theme' 5 | 6 | const MediaWithDefaults = props => 7 | 8 | MediaWithDefaults.defaultProps = { 9 | ...Media.defaultProps, 10 | icons, 11 | theme, 12 | } 13 | 14 | // eslint-disable-next-line react/forbid-foreign-prop-types 15 | MediaWithDefaults.propTypes = Media.propTypes 16 | 17 | export default MediaWithDefaults 18 | -------------------------------------------------------------------------------- /src/components/composeStyle.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Composes styles and/or classes 3 | * 4 | * For classes it will concat them in in one string 5 | * and return as `className` property. 6 | * Alternative is https://github.com/JedWatson/classnames 7 | * 8 | * For objects it will merge them in one object 9 | * and return as `style` property. 10 | * 11 | * Usage: 12 | * Asume you have `theme` object, which can be css-module 13 | * or object or other css-in-js compatible with css-module 14 | * 15 | * link 16 | * 17 | * @returns {{className: string, style: object}} - params for React component 18 | */ 19 | export default (...stylesOrClasses) => { 20 | const classes = [] 21 | let style 22 | for (const obj of stylesOrClasses) { 23 | if (obj instanceof Object) { 24 | Object.assign(style || (style = {}), obj) 25 | } else if (obj === undefined || obj === false) { 26 | // ignore 27 | } else if (typeof obj === 'string') { 28 | classes.push(obj) 29 | } else { 30 | throw new Error(`Unexpected value ${obj}`) 31 | } 32 | } 33 | return { 34 | className: classes.length > 1 ? classes.join(' ') : classes[0], 35 | style, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/constants.js: -------------------------------------------------------------------------------- 1 | const load = 'load' 2 | const loading = 'loading' 3 | const loaded = 'loaded' 4 | const error = 'error' 5 | const noicon = 'noicon' 6 | const offline = 'offline' 7 | 8 | export const icons = { 9 | load, 10 | loading, 11 | loaded, 12 | error, 13 | noicon, 14 | offline, 15 | } 16 | 17 | const initial = 'initial' 18 | 19 | export const loadStates = { 20 | initial, 21 | loading, 22 | loaded, 23 | error, 24 | } 25 | -------------------------------------------------------------------------------- /src/components/helpers.js: -------------------------------------------------------------------------------- 1 | export const ssr = 2 | typeof window === 'undefined' || window.navigator.userAgent === 'ReactSnap' 3 | 4 | export const nativeConnection = !ssr && !!window.navigator.connection 5 | 6 | // export const getScreenWidth = () => { 7 | // if (ssr) return 0 8 | // const devicePixelRatio = window.devicePixelRatio || 1 9 | // const {screen} = window 10 | // const {width} = screen 11 | // // const angle = (screen.orientation && screen.orientation.angle) || 0 12 | // // return Math.max(width, height) 13 | // // const rotated = Math.floor(angle / 90) % 2 !== 0 14 | // // return (rotated ? height : width) * devicePixelRatio 15 | // return width * devicePixelRatio 16 | // } 17 | // export const screenWidth = getScreenWidth() 18 | 19 | export const guessMaxImageWidth = (dimensions, w) => { 20 | if (ssr) return 0 21 | 22 | // Default to window object but don't use window as a default 23 | // parameter so that this can be used on the server as well 24 | if (!w) { 25 | w = window 26 | } 27 | 28 | const imgWidth = dimensions.width 29 | 30 | const {screen} = w 31 | const sWidth = screen.width 32 | const sHeight = screen.height 33 | 34 | const {documentElement} = document 35 | const windowWidth = w.innerWidth || documentElement.clientWidth 36 | const windowHeight = w.innerHeight || documentElement.clientHeight 37 | const devicePixelRatio = w.devicePixelRatio || 1 38 | 39 | const windowResized = sWidth > windowWidth 40 | 41 | let result 42 | if (windowResized) { 43 | const body = document.getElementsByTagName('body')[0] 44 | const scrollWidth = windowWidth - imgWidth 45 | const isScroll = 46 | body.clientHeight > windowHeight || body.clientHeight > sHeight 47 | if (isScroll && scrollWidth <= 15) { 48 | result = sWidth - scrollWidth 49 | } else { 50 | result = (imgWidth / windowWidth) * sWidth 51 | } 52 | } else { 53 | result = imgWidth 54 | } 55 | 56 | return result * devicePixelRatio 57 | } 58 | 59 | export const bytesToSize = bytes => { 60 | const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'] 61 | if (bytes === 0) return 'n/a' 62 | const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)), 10) 63 | if (i === 0) return `${bytes} ${sizes[i]}` 64 | return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}` 65 | } 66 | 67 | // async function supportsWebp() { 68 | // if (typeof createImageBitmap === 'undefined' || typeof fetch === 'undefined') 69 | // return false 70 | // return fetch( 71 | // 'data:image/webp;base64,UklGRh4AAABXRUJQVlA4TBEAAAAvAAAAAAfQ//73v/+BiOh/AAA=', 72 | // ) 73 | // .then(response => response.blob()) 74 | // .then(blob => createImageBitmap(blob).then(() => true, () => false)) 75 | // } 76 | // let webp = undefined 77 | // const webpPromise = supportsWebp() 78 | // webpPromise.then(x => (webp = x)) 79 | // export default () => { 80 | // if (webp === undefined) return webpPromise 81 | // return { 82 | // then: callback => callback(webp), 83 | // } 84 | // } 85 | 86 | const detectWebpSupport = () => { 87 | if (ssr) return false 88 | const elem = document.createElement('canvas') 89 | if (elem.getContext && elem.getContext('2d')) { 90 | // was able or not to get WebP representation 91 | return elem.toDataURL('image/webp').indexOf('data:image/webp') === 0 92 | } else { 93 | // very old browser like IE 8, canvas not supported 94 | return false 95 | } 96 | } 97 | 98 | export const supportsWebp = detectWebpSupport() 99 | 100 | const isWebp = x => 101 | x.format === 'webp' || (x.src && x.src.match(/\.webp($|\?.*)/i)) 102 | 103 | // eslint-disable-next-line no-shadow 104 | export const selectSrc = ({srcSet, maxImageWidth, supportsWebp}) => { 105 | if (srcSet.length === 0) throw new Error('Need at least one item in srcSet') 106 | let supportedFormat, width 107 | if (supportsWebp) { 108 | supportedFormat = srcSet.filter(isWebp) 109 | if (supportedFormat.length === 0) supportedFormat = srcSet 110 | } else { 111 | supportedFormat = srcSet.filter(x => !isWebp(x)) 112 | if (supportedFormat.length === 0) 113 | throw new Error('Need at least one supported format item in srcSet') 114 | } 115 | let widths = supportedFormat.filter(x => x.width >= maxImageWidth) 116 | if (widths.length === 0) { 117 | widths = supportedFormat 118 | width = Math.max.apply(null, widths.map(x => x.width)) 119 | } else { 120 | width = Math.min.apply(null, widths.map(x => x.width)) 121 | } 122 | return supportedFormat.filter(x => x.width === width)[0] 123 | } 124 | 125 | export const fallbackParams = ({srcSet, getUrl}) => { 126 | if (!ssr) return {} 127 | const notWebp = srcSet.filter(x => !isWebp(x)) 128 | const first = notWebp[0] 129 | return { 130 | nsSrcSet: notWebp 131 | .map(x => `${getUrl ? getUrl(x) : x.src} ${x.width}w`) 132 | .join(','), 133 | nsSrc: getUrl ? getUrl(first) : first.src, 134 | ssr, 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/components/icons.js: -------------------------------------------------------------------------------- 1 | import DownloadIcon from './Icon/Download' 2 | import OfflineIcon from './Icon/Offline' 3 | import WarningIcon from './Icon/Warning' 4 | import LoadingIcon from './Icon/Loading' 5 | import {icons} from './constants' 6 | 7 | const {load, loading, loaded, error, noicon, offline} = icons 8 | 9 | export default { 10 | [load]: DownloadIcon, 11 | [loading]: LoadingIcon, 12 | [loaded]: null, 13 | [error]: WarningIcon, 14 | [noicon]: null, 15 | [offline]: OfflineIcon, 16 | } 17 | -------------------------------------------------------------------------------- /src/components/loaders.js: -------------------------------------------------------------------------------- 1 | // There is an issue with cancelable interface 2 | // It is not obvious that 3 | // `image(src)` has `cancel` function 4 | // but `image(src).then()` doesn't 5 | 6 | import {unfetch, UnfetchAbortController} from './unfetch' 7 | 8 | /** 9 | * returns new "promise" with cancel function combined 10 | * 11 | * @param {Promise} p1 - first "promise" with cancel 12 | * @param {Promise} p2 - second "promise" with cancel 13 | * @returns {Promise} - new "promise" with cancel 14 | */ 15 | export const combineCancel = (p1, p2) => { 16 | if (!p2) return p1 17 | const result = p1.then(x => x, x => x) 18 | result.cancel = () => { 19 | p1.cancel() 20 | p2.cancel() 21 | } 22 | return result 23 | } 24 | 25 | export const timeout = threshold => { 26 | let timerId 27 | const result = new Promise(resolve => { 28 | timerId = setTimeout(resolve, threshold) 29 | }) 30 | result.cancel = () => { 31 | // there is a bug with cancel somewhere in the code 32 | // if (!timerId) throw new Error('Already canceled') 33 | clearTimeout(timerId) 34 | timerId = undefined 35 | } 36 | return result 37 | } 38 | 39 | // Caveat: image loader can not cancel download in some browsers 40 | export const imageLoader = src => { 41 | let img = new Image() 42 | const result = new Promise((resolve, reject) => { 43 | img.onload = resolve 44 | // eslint-disable-next-line no-multi-assign 45 | img.onabort = img.onerror = () => reject({}) 46 | img.src = src 47 | }) 48 | result.cancel = () => { 49 | if (!img) throw new Error('Already canceled') 50 | // eslint-disable-next-line no-multi-assign 51 | img.onload = img.onabort = img.onerror = undefined 52 | img.src = '' 53 | img = undefined 54 | } 55 | return result 56 | } 57 | 58 | // Caveat: XHR loader can cause errors because of 'Access-Control-Allow-Origin' 59 | // Caveat: we still need imageLoader to do actual decoding, 60 | // but if images are uncachable this will lead to two requests 61 | export const xhrLoader = (url, options) => { 62 | let controller = new UnfetchAbortController() 63 | const signal = controller.signal 64 | const result = new Promise((resolve, reject) => 65 | unfetch(url, {...options, signal}).then(response => { 66 | if (response.ok) { 67 | response 68 | .blob() 69 | .then(() => imageLoader(url)) 70 | .then(resolve) 71 | } else { 72 | reject({status: response.status}) 73 | } 74 | }, reject), 75 | ) 76 | result.cancel = () => { 77 | if (!controller) throw new Error('Already canceled') 78 | controller.abort() 79 | controller = undefined 80 | } 81 | return result 82 | } 83 | 84 | // Caveat: AbortController only supported in Chrome 66+ 85 | // Caveat: we still need imageLoader to do actual decoding, 86 | // but if images are uncachable this will lead to two requests 87 | // export const fetchLoader = (url, options) => { 88 | // let controller = new AbortController() 89 | // const signal = controller.signal 90 | // const result = new Promise((resolve, reject) => 91 | // fetch(url, {...options, signal}).then(response => { 92 | // if (response.ok) { 93 | // options && options.onMeta && options.onMeta(response.headers) 94 | // response 95 | // .blob() 96 | // .then(() => imageLoader(url)) 97 | // .then(resolve) 98 | // } else { 99 | // reject({status: response.status}) 100 | // } 101 | // }, reject), 102 | // ) 103 | // result.cancel = () => { 104 | // if (!controller) throw new Error('Already canceled') 105 | // controller.abort() 106 | // controller = undefined 107 | // } 108 | // return result 109 | // } 110 | -------------------------------------------------------------------------------- /src/components/theme.js: -------------------------------------------------------------------------------- 1 | export default { 2 | placeholder: { 3 | backgroundSize: 'cover', 4 | backgroundRepeat: 'no-repeat', 5 | position: 'relative', 6 | }, 7 | img: { 8 | width: '100%', 9 | height: 'auto', 10 | maxWidth: '100%', 11 | /* TODO: fix bug in styles */ 12 | marginBottom: '-4px', 13 | }, 14 | icon: { 15 | position: 'absolute', 16 | top: '50%', 17 | left: '50%', 18 | transform: 'translate(-50%, -50%)', 19 | textAlign: 'center', 20 | }, 21 | noscript: { 22 | position: 'absolute', 23 | top: 0, 24 | left: 0, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/components/theme.module.css: -------------------------------------------------------------------------------- 1 | .placeholder { 2 | background-size: cover; 3 | background-repeat: no-repeat; 4 | position: relative; 5 | } 6 | 7 | .img { 8 | width: 100%; 9 | height: auto; 10 | max-width: 100%; 11 | /* TODO: fix bug in styles */ 12 | margin-bottom: -4px; 13 | -webkit-transform: translate3d(0, 0, 0); 14 | } 15 | 16 | .icon { 17 | position: absolute; 18 | top: 50%; 19 | left: 50%; 20 | transform: translate(-50%, -50%); 21 | text-align: center; 22 | } 23 | 24 | .noscript { 25 | position: absolute; 26 | top: 0; 27 | left: 0; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/unfetch.js: -------------------------------------------------------------------------------- 1 | export class UnfetchAbortController { 2 | constructor() { 3 | this.signal = {onabort: () => {}} 4 | this.abort = () => this.signal.onabort() 5 | } 6 | } 7 | 8 | // modified version of https://github.com/developit/unfetch 9 | // - ponyfill intead of polyfill 10 | // - add support for AbortController 11 | export const unfetch = (url, options) => { 12 | options = options || {} 13 | return new Promise((resolve, reject) => { 14 | const request = new XMLHttpRequest() 15 | 16 | request.open(options.method || 'get', url, true) 17 | 18 | // eslint-disable-next-line guard-for-in 19 | for (const i in options.headers) { 20 | request.setRequestHeader(i, options.headers[i]) 21 | } 22 | 23 | request.withCredentials = options.credentials === 'include' 24 | 25 | request.onload = () => { 26 | resolve(response()) 27 | } 28 | 29 | request.onerror = reject 30 | 31 | if (options.signal) 32 | options.signal.onabort = () => { 33 | // eslint-disable-next-line no-multi-assign 34 | request.onerror = request.onload = undefined 35 | request.abort() 36 | } 37 | 38 | request.send(options.body) 39 | 40 | function response() { 41 | const keys = [] 42 | const all = [] 43 | const headers = {} 44 | let header 45 | 46 | request 47 | .getAllResponseHeaders() 48 | .replace(/^(.*?):\s*?([\s\S]*?)$/gm, (m, key, value) => { 49 | keys.push((key = key.toLowerCase())) 50 | all.push([key, value]) 51 | header = headers[key] 52 | headers[key] = header ? `${header},${value}` : value 53 | }) 54 | 55 | return { 56 | // eslint-disable-next-line no-bitwise 57 | ok: ((request.status / 100) | 0) === 2, // 200-299 58 | status: request.status, 59 | statusText: request.statusText, 60 | url: request.responseURL, 61 | clone: response, 62 | text: () => Promise.resolve(request.responseText), 63 | json: () => Promise.resolve(request.responseText).then(JSON.parse), 64 | blob: () => Promise.resolve(new Blob([request.response])), 65 | headers: { 66 | keys: () => keys, 67 | entries: () => all, 68 | get: n => headers[n.toLowerCase()], 69 | has: n => n.toLowerCase() in headers, 70 | }, 71 | } 72 | } 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import IdealImage from './components/IdealImageWithDefaults' 2 | 3 | export default IdealImage 4 | -------------------------------------------------------------------------------- /styleguide.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'react-ideal-image', 3 | components: 'src/components/**/index.js', 4 | skipComponentsWithoutExample: true, 5 | assetsDir: 'other/images', 6 | webpackConfig: { 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.jsx?$/, 11 | exclude: /node_modules/, 12 | loader: 'babel-loader', 13 | }, 14 | ], 15 | }, 16 | externals: { 17 | react: 'react', 18 | 'react-dom': 'react-dom', 19 | 'prop-types': 'prop-types', 20 | 'react-waypoint': 'react-waypoint', 21 | }, 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "noUnusedLocals": true, 5 | "strict": true, 6 | "noImplicitReturns": true, 7 | "noUnusedParameters": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "noEmitOnError": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "lib": ["es2016", "dom"] 12 | }, 13 | "include": ["src/__tests__/**.tsx"] 14 | } 15 | --------------------------------------------------------------------------------