├── .eslintignore ├── .eslintrc.js ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── babel.config.js ├── example ├── CHANGELOG.md └── package.json ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── web-image-loader │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── LICENSE.md │ ├── README.md │ ├── babel.config.js │ ├── package.json │ ├── src │ │ ├── AdaptiveImage.ts │ │ ├── Converter.ts │ │ ├── ImageResolver.ts │ │ ├── ImageSizeResolver.ts │ │ ├── ImageWrapper.ts │ │ ├── Types.ts │ │ ├── __mocks__ │ │ │ ├── Converter.ts │ │ │ └── ImageSizeResolver.ts │ │ ├── __tests__ │ │ │ ├── ImageResolver-test.ts │ │ │ ├── ImageWrapper-test.ts │ │ │ └── __snapshots__ │ │ │ │ ├── ImageResolver-test.ts.snap │ │ │ │ └── ImageWrapper-test.ts.snap │ │ ├── index.ts │ │ └── options.ts │ └── tsconfig.json └── web-image │ ├── CHANGELOG.md │ ├── LICENSE.md │ ├── README.md │ ├── package.json │ ├── src │ ├── Image.tsx │ └── index.tsx │ └── tsconfig.json ├── tsconfig.base.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/lib 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@react-native-community'], 3 | env: { 4 | browser: true, 5 | }, 6 | rules: { 7 | 'react-native/no-inline-styles': 'off', 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Summary 4 | 5 | 6 | 7 | 8 | ## Test Plan 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | js-tests: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Use Node.js 14.x 11 | uses: actions/setup-node@v1 12 | with: 13 | node-version: 14.x 14 | - name: Restore yarn workspaces 15 | id: yarn-cache 16 | uses: actions/cache@master 17 | with: 18 | path: | 19 | node_modules 20 | */*/node_modules 21 | key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} 22 | - name: Install dependencies 23 | if: steps.yarn-cache.outputs.cache-hit != 'true' 24 | run: yarn install --no-progress --non-interactive 25 | - name: Run tests 26 | run: yarn test 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # node.js 6 | # 7 | node_modules/ 8 | npm-debug.log 9 | yarn-error.log 10 | lerna-debug.log 11 | 12 | 13 | # Xcode 14 | # 15 | build/ 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | xcuserdata 25 | *.xccheckout 26 | *.moved-aside 27 | DerivedData 28 | *.hmap 29 | *.ipa 30 | *.xcuserstate 31 | project.xcworkspace 32 | 33 | 34 | # Android/IntelliJ 35 | # 36 | build/ 37 | .idea 38 | .gradle 39 | local.properties 40 | *.iml 41 | android/gradle 42 | android/gradlew* 43 | 44 | # BUCK 45 | buck-out/ 46 | \.buckd/ 47 | debug.keystore 48 | 49 | # Editor config 50 | .vscode 51 | 52 | # Outputs 53 | coverage 54 | 55 | .tmp 56 | example/android-bundle.js 57 | example/ios-bundle.js 58 | index.android.bundle 59 | index.ios.bundle 60 | 61 | # generated by bob 62 | lib/ 63 | 64 | # Expo 65 | web-build 66 | .expo 67 | 68 | # Typescript 69 | ts-lib 70 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | lib 2 | node_modules 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "jsxBracketSameLine": false, 4 | "printWidth": 80, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "useTabs": false 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web Image 2 | 3 | [![CI](https://github.com/th3rdwave/web-image/workflows/CI/badge.svg)](https://github.com/th3rdwave/web-image/actions?query=workflow%3ACI) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) [![lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg)](https://lerna.js.org/) 4 | 5 | SSR friendly image component that renders to a `` element with screen density and webp support while keeping the same api as React Native ``. 6 | 7 | ## Features 8 | 9 | - Same API and behavior as the react-native Image component. 10 | - Uses modern browser features and is SSR / static website friendly. 11 | - Lazy loading using the html `loading="lazy"` attritute (https://developer.mozilla.org/en-US/docs/Web/Performance/Lazy_loading). 12 | - Automatic avif and webp file generation and loading for supported browsers. 13 | - Density support using the same file naming convention as react-native. 14 | - Automatic image dimensions for local assets. 15 | 16 | ## Install 17 | 18 | ```sh 19 | npm install @th3rdwave/web-image @th3rdwave/web-image-loader 20 | ``` 21 | 22 | ## Usage 23 | 24 | ### Local images 25 | 26 | In your webpack config: 27 | 28 | ```js 29 | { 30 | ... 31 | module: { 32 | rules: [ 33 | { 34 | test: /\.(png|jpe?g|gif)$/, 35 | loader: '@th3rdwave/web-image-loader', 36 | }, 37 | ] 38 | } 39 | } 40 | ``` 41 | 42 | In your app: 43 | 44 | ```js 45 | import { Image } from '@th3rdwave/web-image'; 46 | 47 | ; 48 | ``` 49 | 50 | ### Network images 51 | 52 | This image component can also be used with network image. To support multiple formats and densities you must build an object to use as the source prop. 53 | 54 | ```ts 55 | type Source = { 56 | /** 57 | * Default url to use for the image. 58 | */ 59 | uri: string; 60 | /** 61 | * Responsive image sources. 62 | */ 63 | sources?: Array<{ 64 | /** 65 | * Mime type for this source. 66 | */ 67 | type: string; 68 | /** 69 | * [srcset](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-srcset) for this source type. 70 | */ 71 | srcSet: string; 72 | }>; 73 | }; 74 | ``` 75 | 76 | Example: 77 | 78 | ```js 79 | 96 | ``` 97 | 98 | ## Caveats 99 | 100 | - Curently only a small subset of Image props are implemented. 101 | 102 | ## Example 103 | 104 | TODO 105 | 106 | ## Demo 107 | 108 | - https://www.th3rdwave.coffee/ 109 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | // Used by tests. 2 | module.exports = { 3 | presets: [ 4 | ['@babel/preset-env', { targets: { node: 'current' } }], 5 | '@babel/preset-typescript', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /example/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [0.0.3](https://github.com/th3rdwave/web-image/compare/@th3rdwave/example@0.0.2...@th3rdwave/example@0.0.3) (2020-05-11) 7 | 8 | **Note:** Version bump only for package @th3rdwave/example 9 | 10 | 11 | 12 | 13 | 14 | ## [0.0.2](https://github.com/th3rdwave/web-image/compare/@th3rdwave/example@0.0.1...@th3rdwave/example@0.0.2) (2020-05-11) 15 | 16 | **Note:** Version bump only for package @th3rdwave/example 17 | 18 | 19 | 20 | 21 | 22 | ## 0.0.1 (2020-05-11) 23 | 24 | **Note:** Version bump only for package @th3rdwave/example 25 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@th3rdwave/example", 3 | "version": "0.0.3", 4 | "private": true, 5 | "dependencies": { 6 | "expo": "^37.0.9" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // The directory where Jest should store its cached dependency information 12 | // cacheDirectory: "/private/var/folders/dj/vrrkmv_s2mj85d4h4q_z2r640000gn/T/jest_dx", 13 | 14 | // Automatically clear mock calls and instances between every test 15 | clearMocks: true, 16 | 17 | // Indicates whether the coverage information should be collected while executing the test 18 | // collectCoverage: false, 19 | 20 | // An array of glob patterns indicating a set of files for which coverage information should be collected 21 | // collectCoverageFrom: undefined, 22 | 23 | // The directory where Jest should output its coverage files 24 | // coverageDirectory: undefined, 25 | 26 | // An array of regexp pattern strings used to skip coverage collection 27 | // coveragePathIgnorePatterns: [ 28 | // "/node_modules/" 29 | // ], 30 | 31 | // A list of reporter names that Jest uses when writing coverage reports 32 | // coverageReporters: [ 33 | // "json", 34 | // "text", 35 | // "lcov", 36 | // "clover" 37 | // ], 38 | 39 | // An object that configures minimum threshold enforcement for coverage results 40 | // coverageThreshold: undefined, 41 | 42 | // A path to a custom dependency extractor 43 | // dependencyExtractor: undefined, 44 | 45 | // Make calling deprecated APIs throw helpful error messages 46 | // errorOnDeprecated: false, 47 | 48 | // Force coverage collection from ignored files using an array of glob patterns 49 | // forceCoverageMatch: [], 50 | 51 | // A path to a module which exports an async function that is triggered once before all test suites 52 | // globalSetup: undefined, 53 | 54 | // A path to a module which exports an async function that is triggered once after all test suites 55 | // globalTeardown: undefined, 56 | 57 | // A set of global variables that need to be available in all test environments 58 | // globals: {}, 59 | 60 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 61 | // maxWorkers: "50%", 62 | 63 | // An array of directory names to be searched recursively up from the requiring module's location 64 | // moduleDirectories: [ 65 | // "node_modules" 66 | // ], 67 | 68 | // An array of file extensions your modules use 69 | // moduleFileExtensions: [ 70 | // "js", 71 | // "json", 72 | // "jsx", 73 | // "ts", 74 | // "tsx", 75 | // "node" 76 | // ], 77 | 78 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 79 | // moduleNameMapper: {}, 80 | 81 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 82 | // modulePathIgnorePatterns: [], 83 | 84 | // Activates notifications for test results 85 | // notify: false, 86 | 87 | // An enum that specifies notification mode. Requires { notify: true } 88 | // notifyMode: "failure-change", 89 | 90 | // A preset that is used as a base for Jest's configuration 91 | // preset: undefined, 92 | 93 | // Run tests from one or more projects 94 | // projects: undefined, 95 | 96 | // Use this configuration option to add custom reporters to Jest 97 | // reporters: undefined, 98 | 99 | // Automatically reset mock state between every test 100 | // resetMocks: false, 101 | 102 | // Reset the module registry before running each individual test 103 | // resetModules: false, 104 | 105 | // A path to a custom resolver 106 | // resolver: undefined, 107 | 108 | // Automatically restore mock state between every test 109 | // restoreMocks: false, 110 | 111 | // The root directory that Jest should scan for tests and modules within 112 | // rootDir: undefined, 113 | 114 | // A list of paths to directories that Jest should use to search for files in 115 | roots: ['packages', 'example'], 116 | 117 | // Allows you to use a custom runner instead of Jest's default test runner 118 | // runner: "jest-runner", 119 | 120 | // The paths to modules that run some code to configure or set up the testing environment before each test 121 | // setupFiles: [], 122 | 123 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 124 | // setupFilesAfterEnv: [], 125 | 126 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 127 | // snapshotSerializers: [], 128 | 129 | // The test environment that will be used for testing 130 | testEnvironment: 'node', 131 | 132 | // Options that will be passed to the testEnvironment 133 | // testEnvironmentOptions: {}, 134 | 135 | // Adds a location field to test results 136 | // testLocationInResults: false, 137 | 138 | // The glob patterns Jest uses to detect test files 139 | // testMatch: [ 140 | // "**/__tests__/**/*.[jt]s?(x)", 141 | // "**/?(*.)+(spec|test).[tj]s?(x)" 142 | // ], 143 | 144 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 145 | testPathIgnorePatterns: ['/node_modules/', '/lib/'], 146 | 147 | // The regexp pattern or array of patterns that Jest uses to detect test files 148 | // testRegex: [], 149 | 150 | // This option allows the use of a custom results processor 151 | // testResultsProcessor: undefined, 152 | 153 | // This option allows use of a custom test runner 154 | // testRunner: "jasmine2", 155 | 156 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 157 | // testURL: "http://localhost", 158 | 159 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 160 | // timers: "real", 161 | 162 | // A map from regular expressions to paths to transformers 163 | // transform: undefined, 164 | 165 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 166 | // transformIgnorePatterns: [ 167 | // "/node_modules/" 168 | // ], 169 | 170 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 171 | // unmockedModulePathPatterns: undefined, 172 | 173 | // Indicates whether each individual test should be reported during the run 174 | // verbose: undefined, 175 | 176 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 177 | // watchPathIgnorePatterns: [], 178 | 179 | // Whether to use watchman for file crawling 180 | // watchman: true, 181 | }; 182 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*", "example"], 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "version": "independent", 6 | "command": { 7 | "publish": { 8 | "allowBranch": "main", 9 | "conventionalCommits": true, 10 | "message": "chore: publish", 11 | "ignoreChanges": [ 12 | "**/__fixtures__/**", 13 | "**/__tests__/**", 14 | "**/*.md", 15 | "**/example/**" 16 | ] 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Modern image component for react-native-web", 3 | "private": true, 4 | "workspaces": { 5 | "packages": [ 6 | "packages/*", 7 | "example" 8 | ] 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/th3rdwave/web-image.git" 13 | }, 14 | "author": "Janic Duplessis ", 15 | "scripts": { 16 | "test": "yarn validate:prettier && yarn validate:eslint && yarn validate:typescript && yarn validate:jest", 17 | "validate:eslint": "eslint \"{example,packages}/*/src/**/*.{js,ts,tsx}\"", 18 | "validate:typescript": "tsc --noEmit", 19 | "validate:prettier": "prettier \"{example,packages}/*/src/**/*.{js,ts,tsx}\" --check", 20 | "validate:jest": "jest", 21 | "prerelease": "lerna run clean", 22 | "release": "lerna publish", 23 | "example": "yarn --cwd example" 24 | }, 25 | "devDependencies": { 26 | "@babel/preset-env": "^7.15.0", 27 | "@babel/preset-typescript": "^7.15.0", 28 | "@react-native-community/eslint-config": "^3.0.0", 29 | "@types/jest": "^27.0.1", 30 | "eslint": "^7.32.0", 31 | "eslint-plugin-prettier": "^3.4.1", 32 | "lerna": "^4.0.0", 33 | "jest": "^27.1.0", 34 | "prettier": "^2.3.2", 35 | "typescript": "^4.4.2" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/web-image-loader/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { node: true }, 3 | }; 4 | -------------------------------------------------------------------------------- /packages/web-image-loader/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [0.4.8](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image-loader@0.4.7...@th3rdwave/web-image-loader@0.4.8) (2023-12-02) 7 | 8 | **Note:** Version bump only for package @th3rdwave/web-image-loader 9 | 10 | 11 | 12 | 13 | 14 | ## [0.4.7](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image-loader@0.4.6...@th3rdwave/web-image-loader@0.4.7) (2023-03-13) 15 | 16 | 17 | ### Bug Fixes 18 | 19 | * also export raw ([fe798b2](https://github.com/th3rdwave/web-image/commit/fe798b2b2402cf033b6a3dca8b3ee73851af7ffa)) 20 | 21 | 22 | 23 | 24 | 25 | ## [0.4.6](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image-loader@0.4.5...@th3rdwave/web-image-loader@0.4.6) (2023-03-10) 26 | 27 | **Note:** Version bump only for package @th3rdwave/web-image-loader 28 | 29 | 30 | 31 | 32 | 33 | ## [0.4.5](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image-loader@0.4.4...@th3rdwave/web-image-loader@0.4.5) (2022-10-28) 34 | 35 | **Note:** Version bump only for package @th3rdwave/web-image-loader 36 | 37 | 38 | 39 | 40 | 41 | ## [0.4.4](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image-loader@0.4.3...@th3rdwave/web-image-loader@0.4.4) (2022-01-20) 42 | 43 | **Note:** Version bump only for package @th3rdwave/web-image-loader 44 | 45 | 46 | 47 | 48 | 49 | ## [0.4.3](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image-loader@0.4.2...@th3rdwave/web-image-loader@0.4.3) (2021-11-13) 50 | 51 | **Note:** Version bump only for package @th3rdwave/web-image-loader 52 | 53 | 54 | 55 | 56 | 57 | ## [0.4.2](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image-loader@0.4.1...@th3rdwave/web-image-loader@0.4.2) (2021-10-23) 58 | 59 | **Note:** Version bump only for package @th3rdwave/web-image-loader 60 | 61 | 62 | 63 | 64 | 65 | ## [0.4.1](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image-loader@0.4.0...@th3rdwave/web-image-loader@0.4.1) (2021-10-20) 66 | 67 | **Note:** Version bump only for package @th3rdwave/web-image-loader 68 | 69 | 70 | 71 | 72 | 73 | # [0.4.0](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image-loader@0.3.0...@th3rdwave/web-image-loader@0.4.0) (2021-10-20) 74 | 75 | **Note:** Version bump only for package @th3rdwave/web-image-loader 76 | 77 | 78 | 79 | 80 | 81 | # [0.3.0](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image-loader@0.2.3...@th3rdwave/web-image-loader@0.3.0) (2021-09-11) 82 | 83 | **Note:** Version bump only for package @th3rdwave/web-image-loader 84 | 85 | 86 | 87 | 88 | 89 | ## [0.2.3](https://github.com/th3rdwave/web-image/tree/master/packages/web-image-loader/compare/@th3rdwave/web-image-loader@0.2.2...@th3rdwave/web-image-loader@0.2.3) (2021-06-25) 90 | 91 | **Note:** Version bump only for package @th3rdwave/web-image-loader 92 | 93 | 94 | 95 | 96 | 97 | ## [0.2.2](https://github.com/th3rdwave/web-image/tree/master/packages/web-image-loader/compare/@th3rdwave/web-image-loader@0.2.1...@th3rdwave/web-image-loader@0.2.2) (2021-06-25) 98 | 99 | **Note:** Version bump only for package @th3rdwave/web-image-loader 100 | 101 | 102 | 103 | 104 | 105 | ## [0.2.1](https://github.com/th3rdwave/web-image/tree/master/packages/web-image-loader/compare/@th3rdwave/web-image-loader@0.2.0...@th3rdwave/web-image-loader@0.2.1) (2021-06-24) 106 | 107 | **Note:** Version bump only for package @th3rdwave/web-image-loader 108 | 109 | 110 | 111 | 112 | 113 | # [0.2.0](https://github.com/th3rdwave/web-image/tree/master/packages/web-image-loader/compare/@th3rdwave/web-image-loader@0.1.5...@th3rdwave/web-image-loader@0.2.0) (2020-10-27) 114 | 115 | **Note:** Version bump only for package @th3rdwave/web-image-loader 116 | 117 | 118 | 119 | 120 | 121 | ## [0.1.5](https://github.com/th3rdwave/web-image/tree/master/packages/web-image-loader/compare/@th3rdwave/web-image-loader@0.1.4...@th3rdwave/web-image-loader@0.1.5) (2020-09-29) 122 | 123 | **Note:** Version bump only for package @th3rdwave/web-image-loader 124 | 125 | 126 | 127 | 128 | 129 | ## [0.1.4](https://github.com/th3rdwave/web-image/tree/master/packages/web-image-loader/compare/@th3rdwave/web-image-loader@0.1.3...@th3rdwave/web-image-loader@0.1.4) (2020-05-11) 130 | 131 | **Note:** Version bump only for package @th3rdwave/web-image-loader 132 | 133 | 134 | 135 | 136 | 137 | ## 0.1.3 (2020-05-11) 138 | 139 | **Note:** Version bump only for package @th3rdwave/web-image-loader 140 | 141 | 142 | 143 | 144 | 145 | ## [0.1.2](https://github.com/th3rdwave/web-image/tree/master/packages/web-image-loader/compare/@th3rdwave/image-loader@0.1.1...@th3rdwave/image-loader@0.1.2) (2020-05-11) 146 | 147 | **Note:** Version bump only for package @th3rdwave/image-loader 148 | 149 | 150 | 151 | 152 | 153 | ## 0.1.1 (2020-05-11) 154 | 155 | **Note:** Version bump only for package @th3rdwave/image-loader 156 | -------------------------------------------------------------------------------- /packages/web-image-loader/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Th3rdwave 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 | -------------------------------------------------------------------------------- /packages/web-image-loader/README.md: -------------------------------------------------------------------------------- 1 | # Web Image Loader 2 | 3 | TODO 4 | 5 | Originally based on https://github.com/peter-jozsa/react-native-web-image-loader 6 | -------------------------------------------------------------------------------- /packages/web-image-loader/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: '12.16' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /packages/web-image-loader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@th3rdwave/web-image-loader", 3 | "version": "0.4.8", 4 | "description": "Webpack loader for web-image", 5 | "keywords": [ 6 | "react", 7 | "react-native", 8 | "react-native-web", 9 | "webpack", 10 | "image", 11 | "loader" 12 | ], 13 | "license": "MIT", 14 | "repository": "https://github.com/th3rdwave/web-image/tree/master/packages/web-image-loader", 15 | "bugs": { 16 | "url": "https://github.com/th3rdwave/web-image/issues" 17 | }, 18 | "homepage": "https://github.com/th3rdwave/web-image#readme", 19 | "scripts": { 20 | "prepare": "bob build" 21 | }, 22 | "main": "lib/commonjs/index.js", 23 | "files": [ 24 | "lib/" 25 | ], 26 | "publishConfig": { 27 | "access": "public" 28 | }, 29 | "devDependencies": { 30 | "@babel/parser": "^7.15.3", 31 | "@babel/preset-env": "^7.15.0", 32 | "@babel/preset-typescript": "^7.15.0", 33 | "@react-native-community/bob": "^0.13.1", 34 | "@types/find-cache-dir": "^3.2.1", 35 | "@types/image-size": "^0.8.0", 36 | "@types/loader-utils": "^2.0.3", 37 | "@types/lodash": "^4.14.172", 38 | "@types/mime": "^2.0.3", 39 | "@types/mock-fs": "^4.13.1", 40 | "@types/node": "^16.7.3", 41 | "mock-fs": "^5.0.0", 42 | "typescript": "^4.4.2" 43 | }, 44 | "dependencies": { 45 | "find-cache-dir": "^3.3.2", 46 | "image-size": "^1.0.0", 47 | "loader-utils": "^2.0.0", 48 | "lodash": "4.17.21", 49 | "mime": "^2.5.2", 50 | "schema-utils": "^3.1.1", 51 | "sharp": "^0.33.0" 52 | }, 53 | "@react-native-community/bob": { 54 | "source": "src", 55 | "output": "lib", 56 | "targets": [ 57 | "commonjs" 58 | ] 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/AdaptiveImage.ts: -------------------------------------------------------------------------------- 1 | import { AdaptativeImageData, AdaptativeImageSource } from './Types'; 2 | 3 | export class AdaptiveImage { 4 | protected data: AdaptativeImageData; 5 | 6 | constructor(data: AdaptativeImageData) { 7 | this.data = data; 8 | } 9 | 10 | get uri(): string { 11 | return this.data.uri; 12 | } 13 | 14 | get width(): number { 15 | return this.data.width; 16 | } 17 | 18 | get height(): number { 19 | return this.data.height; 20 | } 21 | 22 | get sources(): AdaptativeImageSource[] | undefined { 23 | return this.data.sources; 24 | } 25 | 26 | toString(): string { 27 | return this.uri; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/Converter.ts: -------------------------------------------------------------------------------- 1 | import sharp from 'sharp'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | import findCacheDir from 'find-cache-dir'; 5 | // @ts-expect-error 6 | import createHash from 'webpack/lib/util/createHash'; 7 | 8 | const hashCache = new WeakMap(); 9 | 10 | sharp.cache(false); 11 | 12 | function hashBuffer(data: Buffer) { 13 | const cached = hashCache.get(data); 14 | if (cached != null) { 15 | return cached; 16 | } 17 | const result = createHash('xxhash64').update(data).digest('hex'); 18 | hashCache.set(data, result); 19 | return result; 20 | } 21 | 22 | class FsCache { 23 | _path: string | undefined; 24 | 25 | constructor() { 26 | this._path = findCacheDir({ name: 'web-image-loader', create: true }); 27 | } 28 | 29 | async get(key: string): Promise { 30 | if (this._path == null) { 31 | return null; 32 | } 33 | try { 34 | return await fs.promises.readFile(path.join(this._path, key)); 35 | } catch (ex) { 36 | return null; 37 | } 38 | } 39 | 40 | async set(key: string, value: Buffer): Promise { 41 | if (this._path == null) { 42 | return; 43 | } 44 | await fs.promises.writeFile(path.join(this._path, key), value); 45 | } 46 | } 47 | 48 | const cache = new FsCache(); 49 | 50 | export async function convertToWebp(image: Buffer): Promise { 51 | const key = hashBuffer(image) + '.webp'; 52 | const cached = await cache.get(key); 53 | if (cached != null) { 54 | return cached; 55 | } 56 | const result = await sharp(image) 57 | .webp({ 58 | quality: 70, 59 | }) 60 | .toBuffer(); 61 | 62 | await cache.set(key, result); 63 | return result; 64 | } 65 | 66 | export async function convertToAvif(image: Buffer): Promise { 67 | const key = hashBuffer(image) + '.avif'; 68 | const cached = await cache.get(key); 69 | if (cached != null) { 70 | return cached; 71 | } 72 | const result = await sharp(image) 73 | .avif({ 74 | quality: 55, 75 | speed: 7, 76 | }) 77 | .toBuffer(); 78 | 79 | await cache.set(key, result); 80 | return result; 81 | } 82 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/ImageResolver.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import { getType } from 'mime'; 3 | import path from 'path'; 4 | import { convertToAvif, convertToWebp } from './Converter'; 5 | import { imageSize, Size } from './ImageSizeResolver'; 6 | import { ResolvedImageSource } from './Types'; 7 | 8 | interface ParsedPath { 9 | dir: string; 10 | name: string; 11 | scale: number; 12 | ext: string; 13 | } 14 | 15 | export function parsePath(resourcePath: string): ParsedPath { 16 | // https://regexr.com/54acj 17 | const match = resourcePath.match( 18 | /^(?:(.*)[\\/])?([^\\/]+?)(?:@([0-9])x)?\.(.+)$/, 19 | ); 20 | if (!match || !match[2] || !match[4]) { 21 | throw new Error(`Unable to parse resource ${resourcePath}.`); 22 | } 23 | return { 24 | dir: match[1] ?? '', 25 | name: match[2], 26 | scale: match[3] != null ? parseFloat(match[3]) : 1, 27 | ext: match[4], 28 | }; 29 | } 30 | 31 | export async function resolveImage( 32 | resourcePath: string, 33 | resourceContent: Buffer, 34 | scales: number[], 35 | formats: { avif: boolean; webp: boolean }, 36 | emitFileCallback: (file: ResolvedImageSource, content: Buffer) => void, 37 | ): Promise { 38 | const fileData = parsePath(resourcePath); 39 | const type = getType(fileData.ext); 40 | if (type == null) { 41 | throw new Error(`Unable to parse mime type for ${resourcePath}.`); 42 | } 43 | const generateModernFormats = type === 'image/png' || type === 'image/jpeg'; 44 | 45 | const getFilePath = (scale: number): string => { 46 | const suffix = scale === 1 ? '' : `@${scale}x`; 47 | return path.join(fileData.dir, `${fileData.name}${suffix}.${fileData.ext}`); 48 | }; 49 | 50 | const addFile = async (scale: number, content: Buffer): Promise => { 51 | const filePath = getFilePath(scale); 52 | emitFileCallback({ uri: filePath, scale, type }, content); 53 | if (generateModernFormats) { 54 | if (formats.avif) { 55 | emitFileCallback( 56 | { 57 | uri: filePath.replace(/\.png|\.jpe?g/, '.avif'), 58 | scale, 59 | type: 'image/avif', 60 | }, 61 | await convertToAvif(content), 62 | ); 63 | } 64 | if (formats.webp) { 65 | emitFileCallback( 66 | { 67 | uri: filePath.replace(/\.png|\.jpe?g/, '.webp'), 68 | scale, 69 | type: 'image/webp', 70 | }, 71 | await convertToWebp(content), 72 | ); 73 | } 74 | } 75 | }; 76 | 77 | // Original file will be passed as `resourceContent`. 78 | await addFile(fileData.scale, resourceContent); 79 | const size = imageSize(resourceContent, fileData.scale); 80 | 81 | // Find other files available for scales. 82 | const missingScales = scales.filter((s) => s !== fileData.scale); 83 | for (const scale of missingScales) { 84 | try { 85 | const filePath = getFilePath(scale); 86 | const stats = await fs.stat(filePath); 87 | if (stats.isFile()) { 88 | const content = await fs.readFile(filePath); 89 | await addFile(scale, content); 90 | } 91 | } catch (e) { 92 | // Do nothing 93 | } 94 | } 95 | 96 | return size; 97 | } 98 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/ImageSizeResolver.ts: -------------------------------------------------------------------------------- 1 | import { imageSize as baseImageSize } from 'image-size'; 2 | 3 | export interface Size { 4 | width: number; 5 | height: number; 6 | } 7 | 8 | export function imageSize(buffer: Buffer, scale: number): Size { 9 | const size = baseImageSize(buffer) as Size; 10 | if (scale !== 1) { 11 | return { width: size.width / scale, height: size.height / scale }; 12 | } else { 13 | return size; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/ImageWrapper.ts: -------------------------------------------------------------------------------- 1 | import groupBy from 'lodash/groupBy'; 2 | import { WebpackResolvedImage } from './Types'; 3 | 4 | const SORT = ['image/webp', 'image/avif']; 5 | 6 | export function createImageWrapper(classPath: string, esModule: boolean) { 7 | return ( 8 | size: { width: number; height: number }, 9 | images: WebpackResolvedImage[], 10 | ): string => { 11 | const imagesByType = groupBy(images, (img) => img.type); 12 | // Template strings are a bit weird here but this is the price 13 | // to pay to make the generated code beautiful :S 14 | const sources = `[ 15 | ${Object.values(imagesByType) 16 | // Make sure avif then webp comes first. 17 | .sort((a, b) => { 18 | const ia = SORT.indexOf(a[0].type); 19 | const ib = SORT.indexOf(b[0].type); 20 | return ib - ia; 21 | }) 22 | .map( 23 | (group) => `{ 24 | srcSet: ${group 25 | .map((img) => `${img.publicPath} + " ${img.scale}x"`) 26 | .join(' + "," +\n ')}, 27 | type: "${group[0].type}" 28 | }`, 29 | ) 30 | .join(',\n ')} 31 | ]`; 32 | 33 | return `${ 34 | esModule 35 | ? `import {AdaptiveImage} from ${classPath}` 36 | : `var AdaptiveImage = require(${classPath}).AdaptiveImage` 37 | }; 38 | 39 | ${esModule ? 'export default' : 'module.exports ='} new AdaptiveImage({ 40 | uri: ${images[0].publicPath}, 41 | width: ${size.width}, 42 | height: ${size.height}, 43 | sources: ${sources} 44 | }); 45 | `; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/Types.ts: -------------------------------------------------------------------------------- 1 | export interface ResolvedImageSource { 2 | /** 3 | * Density scale for this image (1x, 2x, 3x) 4 | */ 5 | scale: number; 6 | /** 7 | * Final uri for the image. 8 | */ 9 | uri: string; 10 | /** 11 | * Mime type 12 | */ 13 | type: string; 14 | } 15 | 16 | export interface WebpackResolvedImage { 17 | outputPath: string; 18 | publicPath: string; 19 | /** 20 | * Density scale for this image (1x, 2x, 3x) 21 | */ 22 | scale: number; 23 | /** 24 | * Mime type 25 | */ 26 | type: string; 27 | } 28 | 29 | export interface AdaptativeImageSource { 30 | /** 31 | * srcset string (https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-srcset) 32 | */ 33 | srcSet: string; 34 | /** 35 | * Mime type 36 | */ 37 | type: string; 38 | } 39 | 40 | export interface AdaptativeImageData { 41 | width: number; 42 | height: number; 43 | uri: string; 44 | sources?: AdaptativeImageSource[]; 45 | } 46 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/__mocks__/Converter.ts: -------------------------------------------------------------------------------- 1 | export async function convertToWebp(): Promise { 2 | return Buffer.from([3, 3, 3, 3]); 3 | } 4 | 5 | export async function convertToAvif(): Promise { 6 | return Buffer.from([9, 9, 9, 9]); 7 | } 8 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/__mocks__/ImageSizeResolver.ts: -------------------------------------------------------------------------------- 1 | import type { Size } from '../ImageSizeResolver'; 2 | 3 | export function imageSize(buffer: Buffer, scale: number): Size { 4 | return { width: buffer.length / scale, height: buffer.length / scale }; 5 | } 6 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/__tests__/ImageResolver-test.ts: -------------------------------------------------------------------------------- 1 | import mockFs from 'mock-fs'; 2 | 3 | import { parsePath, resolveImage } from '../ImageResolver'; 4 | 5 | jest.mock('../Converter').mock('../ImageSizeResolver'); 6 | 7 | const SCALES = [1, 2, 3]; 8 | const FORMATS = { avif: true, webp: true }; 9 | const BUFFER = Buffer.from([1, 3, 3, 7]); 10 | 11 | describe('ImageResolver', () => { 12 | describe('parsePath', () => { 13 | it('handles paths with no scale', () => { 14 | expect(parsePath('/some/url/image.png')).toMatchInlineSnapshot(` 15 | Object { 16 | "dir": "/some/url", 17 | "ext": "png", 18 | "name": "image", 19 | "scale": 1, 20 | } 21 | `); 22 | }); 23 | 24 | it('handles paths with scales', () => { 25 | expect(parsePath('/some/url/image@3x.png')).toMatchInlineSnapshot(` 26 | Object { 27 | "dir": "/some/url", 28 | "ext": "png", 29 | "name": "image", 30 | "scale": 3, 31 | } 32 | `); 33 | }); 34 | 35 | it('handles paths with no directory', () => { 36 | expect(parsePath('image@3x.png')).toMatchInlineSnapshot(` 37 | Object { 38 | "dir": "", 39 | "ext": "png", 40 | "name": "image", 41 | "scale": 3, 42 | } 43 | `); 44 | }); 45 | 46 | it('throws on invalid paths', () => { 47 | expect(() => parsePath('/some/url/image@3x')).toThrow(); 48 | expect(() => parsePath('/some/url/')).toThrow(); 49 | expect(() => parsePath('/some/url/.png')).toThrow(); 50 | // TODO: This should probably throw. 51 | // expect(() => parsePath('/some/url/@2x.png')).toThrow() 52 | }); 53 | 54 | it('handles windows paths', () => { 55 | expect(parsePath('\\some\\url\\image@3x.png')).toMatchInlineSnapshot(` 56 | Object { 57 | "dir": "\\\\some\\\\url", 58 | "ext": "png", 59 | "name": "image", 60 | "scale": 3, 61 | } 62 | `); 63 | }); 64 | }); 65 | 66 | describe('resolveImage', () => { 67 | beforeEach(() => { 68 | mockFs({ 69 | '/some/url': { 70 | 'image.png': '1', 71 | 'image@2x.png': '2', 72 | 'image@3x.png': '3', 73 | 'image@4x.png': '4', 74 | 'anim.gif': '5', 75 | 'anim@2x.gif': '6', 76 | 'anim@3x.gif': '7', 77 | 'lonely.png': '8', 78 | 'weird@2x.png': '9', 79 | 'wow.png': '10', 80 | 'wow@4x.png': '11', 81 | }, 82 | }); 83 | }); 84 | 85 | afterEach(() => { 86 | mockFs.restore(); 87 | }); 88 | 89 | it('resolves images with modern format support', async () => { 90 | const emitFile = jest.fn(); 91 | const size = await resolveImage( 92 | '/some/url/image.png', 93 | BUFFER, 94 | SCALES, 95 | FORMATS, 96 | emitFile, 97 | ); 98 | expect(size).toMatchSnapshot(); 99 | expect(emitFile.mock.calls).toMatchSnapshot(); 100 | }); 101 | 102 | it('resolves images without modern formats support', async () => { 103 | const emitFile = jest.fn(); 104 | const size = await resolveImage( 105 | '/some/url/anim.gif', 106 | BUFFER, 107 | SCALES, 108 | FORMATS, 109 | emitFile, 110 | ); 111 | expect(size).toMatchSnapshot(); 112 | expect(emitFile.mock.calls).toMatchSnapshot(); 113 | }); 114 | 115 | it('resolves images with missing scales', async () => { 116 | const emitFile = jest.fn(); 117 | const size = await resolveImage( 118 | '/some/url/lonely.png', 119 | BUFFER, 120 | SCALES, 121 | FORMATS, 122 | emitFile, 123 | ); 124 | expect(size).toMatchSnapshot(); 125 | expect(emitFile.mock.calls).toMatchSnapshot(); 126 | }); 127 | 128 | it('resolves images with scale suffix', async () => { 129 | const emitFile = jest.fn(); 130 | const size = await resolveImage( 131 | '/some/url/weird@2x.png', 132 | BUFFER, 133 | SCALES, 134 | FORMATS, 135 | emitFile, 136 | ); 137 | expect(size).toMatchSnapshot(); 138 | expect(emitFile.mock.calls).toMatchSnapshot(); 139 | }); 140 | 141 | it('ignores images outside of passed scales', async () => { 142 | const emitFile = jest.fn(); 143 | const size = await resolveImage( 144 | '/some/url/wow.png', 145 | BUFFER, 146 | SCALES, 147 | FORMATS, 148 | emitFile, 149 | ); 150 | expect(size).toMatchSnapshot(); 151 | expect(emitFile.mock.calls).toMatchSnapshot(); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/__tests__/ImageWrapper-test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from '@babel/parser'; 2 | import { createImageWrapper } from '../ImageWrapper'; 3 | import { WebpackResolvedImage } from '../Types'; 4 | 5 | const IMAGE_WRAPPER_PATH = 6 | '"../../node_modules/@th3rdwave/web-image-loader/lib/commonjs/AdaptiveImage"'; 7 | 8 | const IMAGE_SIZE = { width: 100, height: 100 }; 9 | 10 | const createImages = (image: string): WebpackResolvedImage[] => 11 | Array.from({ length: 3 }).map((_, i) => { 12 | const [name, ext] = image.split('.'); 13 | return { 14 | publicPath: `__webpack_public_path__ + "static/${name}@${i + 1}x.${ext}"`, 15 | outputPath: image, 16 | scale: i + 1, 17 | type: `image/${ext}`, 18 | }; 19 | }); 20 | 21 | describe('ImageWrapper', () => { 22 | it('generates valid javascript', () => { 23 | const imageWrapper = createImageWrapper(IMAGE_WRAPPER_PATH, true); 24 | const code = imageWrapper(IMAGE_SIZE, [ 25 | ...createImages('img.png'), 26 | ...createImages('img.webp'), 27 | ]); 28 | expect(parse(code, { sourceType: 'module' })).toEqual(expect.any(Object)); 29 | }); 30 | 31 | it('generates es modules source', () => { 32 | const imageWrapper = createImageWrapper(IMAGE_WRAPPER_PATH, true); 33 | expect(imageWrapper(IMAGE_SIZE, createImages('img.png'))).toMatchSnapshot(); 34 | }); 35 | 36 | it('generates commonjs source', () => { 37 | const imageWrapper = createImageWrapper(IMAGE_WRAPPER_PATH, false); 38 | expect(imageWrapper(IMAGE_SIZE, createImages('img.png'))).toMatchSnapshot(); 39 | }); 40 | 41 | it('handles multiple sources', () => { 42 | const imageWrapper = createImageWrapper(IMAGE_WRAPPER_PATH, true); 43 | expect( 44 | imageWrapper(IMAGE_SIZE, [ 45 | ...createImages('img.png'), 46 | ...createImages('img.avif'), 47 | ...createImages('img.webp'), 48 | ]), 49 | ).toMatchSnapshot(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/__tests__/__snapshots__/ImageResolver-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ImageResolver resolveImage ignores images outside of passed scales 1`] = ` 4 | Object { 5 | "height": 4, 6 | "width": 4, 7 | } 8 | `; 9 | 10 | exports[`ImageResolver resolveImage ignores images outside of passed scales 2`] = ` 11 | Array [ 12 | Array [ 13 | Object { 14 | "scale": 1, 15 | "type": "image/png", 16 | "uri": "/some/url/wow.png", 17 | }, 18 | Object { 19 | "data": Array [ 20 | 1, 21 | 3, 22 | 3, 23 | 7, 24 | ], 25 | "type": "Buffer", 26 | }, 27 | ], 28 | Array [ 29 | Object { 30 | "scale": 1, 31 | "type": "image/avif", 32 | "uri": "/some/url/wow.avif", 33 | }, 34 | Object { 35 | "data": Array [ 36 | 9, 37 | 9, 38 | 9, 39 | 9, 40 | ], 41 | "type": "Buffer", 42 | }, 43 | ], 44 | Array [ 45 | Object { 46 | "scale": 1, 47 | "type": "image/webp", 48 | "uri": "/some/url/wow.webp", 49 | }, 50 | Object { 51 | "data": Array [ 52 | 3, 53 | 3, 54 | 3, 55 | 3, 56 | ], 57 | "type": "Buffer", 58 | }, 59 | ], 60 | ] 61 | `; 62 | 63 | exports[`ImageResolver resolveImage resolves images with missing scales 1`] = ` 64 | Object { 65 | "height": 4, 66 | "width": 4, 67 | } 68 | `; 69 | 70 | exports[`ImageResolver resolveImage resolves images with missing scales 2`] = ` 71 | Array [ 72 | Array [ 73 | Object { 74 | "scale": 1, 75 | "type": "image/png", 76 | "uri": "/some/url/lonely.png", 77 | }, 78 | Object { 79 | "data": Array [ 80 | 1, 81 | 3, 82 | 3, 83 | 7, 84 | ], 85 | "type": "Buffer", 86 | }, 87 | ], 88 | Array [ 89 | Object { 90 | "scale": 1, 91 | "type": "image/avif", 92 | "uri": "/some/url/lonely.avif", 93 | }, 94 | Object { 95 | "data": Array [ 96 | 9, 97 | 9, 98 | 9, 99 | 9, 100 | ], 101 | "type": "Buffer", 102 | }, 103 | ], 104 | Array [ 105 | Object { 106 | "scale": 1, 107 | "type": "image/webp", 108 | "uri": "/some/url/lonely.webp", 109 | }, 110 | Object { 111 | "data": Array [ 112 | 3, 113 | 3, 114 | 3, 115 | 3, 116 | ], 117 | "type": "Buffer", 118 | }, 119 | ], 120 | ] 121 | `; 122 | 123 | exports[`ImageResolver resolveImage resolves images with modern format support 1`] = ` 124 | Object { 125 | "height": 4, 126 | "width": 4, 127 | } 128 | `; 129 | 130 | exports[`ImageResolver resolveImage resolves images with modern format support 2`] = ` 131 | Array [ 132 | Array [ 133 | Object { 134 | "scale": 1, 135 | "type": "image/png", 136 | "uri": "/some/url/image.png", 137 | }, 138 | Object { 139 | "data": Array [ 140 | 1, 141 | 3, 142 | 3, 143 | 7, 144 | ], 145 | "type": "Buffer", 146 | }, 147 | ], 148 | Array [ 149 | Object { 150 | "scale": 1, 151 | "type": "image/avif", 152 | "uri": "/some/url/image.avif", 153 | }, 154 | Object { 155 | "data": Array [ 156 | 9, 157 | 9, 158 | 9, 159 | 9, 160 | ], 161 | "type": "Buffer", 162 | }, 163 | ], 164 | Array [ 165 | Object { 166 | "scale": 1, 167 | "type": "image/webp", 168 | "uri": "/some/url/image.webp", 169 | }, 170 | Object { 171 | "data": Array [ 172 | 3, 173 | 3, 174 | 3, 175 | 3, 176 | ], 177 | "type": "Buffer", 178 | }, 179 | ], 180 | Array [ 181 | Object { 182 | "scale": 2, 183 | "type": "image/png", 184 | "uri": "/some/url/image@2x.png", 185 | }, 186 | Object { 187 | "data": Array [ 188 | 50, 189 | ], 190 | "type": "Buffer", 191 | }, 192 | ], 193 | Array [ 194 | Object { 195 | "scale": 2, 196 | "type": "image/avif", 197 | "uri": "/some/url/image@2x.avif", 198 | }, 199 | Object { 200 | "data": Array [ 201 | 9, 202 | 9, 203 | 9, 204 | 9, 205 | ], 206 | "type": "Buffer", 207 | }, 208 | ], 209 | Array [ 210 | Object { 211 | "scale": 2, 212 | "type": "image/webp", 213 | "uri": "/some/url/image@2x.webp", 214 | }, 215 | Object { 216 | "data": Array [ 217 | 3, 218 | 3, 219 | 3, 220 | 3, 221 | ], 222 | "type": "Buffer", 223 | }, 224 | ], 225 | Array [ 226 | Object { 227 | "scale": 3, 228 | "type": "image/png", 229 | "uri": "/some/url/image@3x.png", 230 | }, 231 | Object { 232 | "data": Array [ 233 | 51, 234 | ], 235 | "type": "Buffer", 236 | }, 237 | ], 238 | Array [ 239 | Object { 240 | "scale": 3, 241 | "type": "image/avif", 242 | "uri": "/some/url/image@3x.avif", 243 | }, 244 | Object { 245 | "data": Array [ 246 | 9, 247 | 9, 248 | 9, 249 | 9, 250 | ], 251 | "type": "Buffer", 252 | }, 253 | ], 254 | Array [ 255 | Object { 256 | "scale": 3, 257 | "type": "image/webp", 258 | "uri": "/some/url/image@3x.webp", 259 | }, 260 | Object { 261 | "data": Array [ 262 | 3, 263 | 3, 264 | 3, 265 | 3, 266 | ], 267 | "type": "Buffer", 268 | }, 269 | ], 270 | ] 271 | `; 272 | 273 | exports[`ImageResolver resolveImage resolves images with scale suffix 1`] = ` 274 | Object { 275 | "height": 2, 276 | "width": 2, 277 | } 278 | `; 279 | 280 | exports[`ImageResolver resolveImage resolves images with scale suffix 2`] = ` 281 | Array [ 282 | Array [ 283 | Object { 284 | "scale": 2, 285 | "type": "image/png", 286 | "uri": "/some/url/weird@2x.png", 287 | }, 288 | Object { 289 | "data": Array [ 290 | 1, 291 | 3, 292 | 3, 293 | 7, 294 | ], 295 | "type": "Buffer", 296 | }, 297 | ], 298 | Array [ 299 | Object { 300 | "scale": 2, 301 | "type": "image/avif", 302 | "uri": "/some/url/weird@2x.avif", 303 | }, 304 | Object { 305 | "data": Array [ 306 | 9, 307 | 9, 308 | 9, 309 | 9, 310 | ], 311 | "type": "Buffer", 312 | }, 313 | ], 314 | Array [ 315 | Object { 316 | "scale": 2, 317 | "type": "image/webp", 318 | "uri": "/some/url/weird@2x.webp", 319 | }, 320 | Object { 321 | "data": Array [ 322 | 3, 323 | 3, 324 | 3, 325 | 3, 326 | ], 327 | "type": "Buffer", 328 | }, 329 | ], 330 | ] 331 | `; 332 | 333 | exports[`ImageResolver resolveImage resolves images without modern formats support 1`] = ` 334 | Object { 335 | "height": 4, 336 | "width": 4, 337 | } 338 | `; 339 | 340 | exports[`ImageResolver resolveImage resolves images without modern formats support 2`] = ` 341 | Array [ 342 | Array [ 343 | Object { 344 | "scale": 1, 345 | "type": "image/gif", 346 | "uri": "/some/url/anim.gif", 347 | }, 348 | Object { 349 | "data": Array [ 350 | 1, 351 | 3, 352 | 3, 353 | 7, 354 | ], 355 | "type": "Buffer", 356 | }, 357 | ], 358 | Array [ 359 | Object { 360 | "scale": 2, 361 | "type": "image/gif", 362 | "uri": "/some/url/anim@2x.gif", 363 | }, 364 | Object { 365 | "data": Array [ 366 | 54, 367 | ], 368 | "type": "Buffer", 369 | }, 370 | ], 371 | Array [ 372 | Object { 373 | "scale": 3, 374 | "type": "image/gif", 375 | "uri": "/some/url/anim@3x.gif", 376 | }, 377 | Object { 378 | "data": Array [ 379 | 55, 380 | ], 381 | "type": "Buffer", 382 | }, 383 | ], 384 | ] 385 | `; 386 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/__tests__/__snapshots__/ImageWrapper-test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`ImageWrapper generates commonjs source 1`] = ` 4 | "var AdaptiveImage = require(\\"../../node_modules/@th3rdwave/web-image-loader/lib/commonjs/AdaptiveImage\\").AdaptiveImage; 5 | 6 | module.exports = new AdaptiveImage({ 7 | uri: __webpack_public_path__ + \\"static/img@1x.png\\", 8 | width: 100, 9 | height: 100, 10 | sources: [ 11 | { 12 | srcSet: __webpack_public_path__ + \\"static/img@1x.png\\" + \\" 1x\\" + \\",\\" + 13 | __webpack_public_path__ + \\"static/img@2x.png\\" + \\" 2x\\" + \\",\\" + 14 | __webpack_public_path__ + \\"static/img@3x.png\\" + \\" 3x\\", 15 | type: \\"image/png\\" 16 | } 17 | ] 18 | }); 19 | " 20 | `; 21 | 22 | exports[`ImageWrapper generates es modules source 1`] = ` 23 | "import {AdaptiveImage} from \\"../../node_modules/@th3rdwave/web-image-loader/lib/commonjs/AdaptiveImage\\"; 24 | 25 | export default new AdaptiveImage({ 26 | uri: __webpack_public_path__ + \\"static/img@1x.png\\", 27 | width: 100, 28 | height: 100, 29 | sources: [ 30 | { 31 | srcSet: __webpack_public_path__ + \\"static/img@1x.png\\" + \\" 1x\\" + \\",\\" + 32 | __webpack_public_path__ + \\"static/img@2x.png\\" + \\" 2x\\" + \\",\\" + 33 | __webpack_public_path__ + \\"static/img@3x.png\\" + \\" 3x\\", 34 | type: \\"image/png\\" 35 | } 36 | ] 37 | }); 38 | " 39 | `; 40 | 41 | exports[`ImageWrapper handles multiple sources 1`] = ` 42 | "import {AdaptiveImage} from \\"../../node_modules/@th3rdwave/web-image-loader/lib/commonjs/AdaptiveImage\\"; 43 | 44 | export default new AdaptiveImage({ 45 | uri: __webpack_public_path__ + \\"static/img@1x.png\\", 46 | width: 100, 47 | height: 100, 48 | sources: [ 49 | { 50 | srcSet: __webpack_public_path__ + \\"static/img@1x.avif\\" + \\" 1x\\" + \\",\\" + 51 | __webpack_public_path__ + \\"static/img@2x.avif\\" + \\" 2x\\" + \\",\\" + 52 | __webpack_public_path__ + \\"static/img@3x.avif\\" + \\" 3x\\", 53 | type: \\"image/avif\\" 54 | }, 55 | { 56 | srcSet: __webpack_public_path__ + \\"static/img@1x.webp\\" + \\" 1x\\" + \\",\\" + 57 | __webpack_public_path__ + \\"static/img@2x.webp\\" + \\" 2x\\" + \\",\\" + 58 | __webpack_public_path__ + \\"static/img@3x.webp\\" + \\" 3x\\", 59 | type: \\"image/webp\\" 60 | }, 61 | { 62 | srcSet: __webpack_public_path__ + \\"static/img@1x.png\\" + \\" 1x\\" + \\",\\" + 63 | __webpack_public_path__ + \\"static/img@2x.png\\" + \\" 2x\\" + \\",\\" + 64 | __webpack_public_path__ + \\"static/img@3x.png\\" + \\" 3x\\", 65 | type: \\"image/png\\" 66 | } 67 | ] 68 | }); 69 | " 70 | `; 71 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/index.ts: -------------------------------------------------------------------------------- 1 | import loaderUtils from 'loader-utils'; 2 | import path from 'path'; 3 | import { validate } from 'schema-utils'; 4 | import { loader } from 'webpack'; 5 | import { resolveImage } from './ImageResolver'; 6 | import { createImageWrapper } from './ImageWrapper'; 7 | import schema from './options'; 8 | import { ResolvedImageSource, WebpackResolvedImage } from './Types'; 9 | 10 | const DEFAULT_IMAGE_CLASS_PATH = require.resolve('./AdaptiveImage'); 11 | const DEFAULT_IMAGE_NAME_FORMAT = '[hash][scale].[ext]'; 12 | const DEFAULT_SCALINGS = [1, 2, 3]; 13 | 14 | function interpolateName( 15 | context: loader.LoaderContext, 16 | nameFormat: string, 17 | content: Buffer, 18 | scale: number, 19 | ): string { 20 | return loaderUtils 21 | .interpolateName(context, nameFormat, { 22 | context: context.context, 23 | content, 24 | }) 25 | .replace(/\[scale\]/g, scale === 1 ? '' : `@${scale}x`); 26 | } 27 | 28 | interface Options { 29 | scalings?: number[]; 30 | esModule?: boolean; 31 | name?: string; 32 | imageClassPath?: string; 33 | outputPath?: string; 34 | publicPath?: string | ((url: string, res: string) => string); 35 | formats?: { 36 | avif?: boolean; 37 | webp?: boolean; 38 | }; 39 | } 40 | 41 | function emitAndResolveImage( 42 | context: loader.LoaderContext, 43 | options: Options, 44 | file: ResolvedImageSource, 45 | content: Buffer, 46 | ): WebpackResolvedImage { 47 | const nameFormat = options.name ?? DEFAULT_IMAGE_NAME_FORMAT; 48 | let fileName = interpolateName(context, nameFormat, content, file.scale); 49 | if (file.type === 'image/webp') { 50 | fileName = fileName.replace(/\.png|\.jpe?g/, '.webp'); 51 | } 52 | if (file.type === 'image/avif') { 53 | fileName = fileName.replace(/\.png|\.jpe?g/, '.avif'); 54 | } 55 | 56 | let outputPath = fileName; 57 | if (options.outputPath) { 58 | outputPath = path.posix.join(options.outputPath, fileName); 59 | } 60 | 61 | let publicPath = `__webpack_public_path__ + ${JSON.stringify(outputPath)}`; 62 | if (options.publicPath) { 63 | if (typeof options.publicPath === 'function') { 64 | publicPath = options.publicPath(fileName, file.uri); 65 | } else { 66 | publicPath = `${ 67 | options.publicPath.endsWith('/') 68 | ? options.publicPath 69 | : `${options.publicPath}/` 70 | }${fileName}`; 71 | } 72 | 73 | publicPath = JSON.stringify(publicPath); 74 | } 75 | 76 | context.emitFile(outputPath, content, null); 77 | 78 | return { 79 | outputPath, 80 | publicPath, 81 | type: file.type, 82 | scale: file.scale, 83 | }; 84 | } 85 | 86 | export default async function resolve( 87 | this: loader.LoaderContext, 88 | content: Buffer, 89 | ): Promise { 90 | const callback = this.async()!; 91 | 92 | const options = loaderUtils.getOptions(this) as Options; 93 | 94 | validate(schema, options, { 95 | name: 'React Native Web Image Loader', 96 | baseDataPath: 'options', 97 | }); 98 | 99 | const esModule = 100 | typeof options.esModule !== 'undefined' ? options.esModule : false; 101 | const scalings = options.scalings ?? DEFAULT_SCALINGS; 102 | const formats = { 103 | avif: options.formats?.avif ?? true, 104 | webp: options.formats?.webp ?? true, 105 | }; 106 | const wrapImage = createImageWrapper( 107 | loaderUtils.stringifyRequest( 108 | this, 109 | options.imageClassPath || DEFAULT_IMAGE_CLASS_PATH, 110 | ), 111 | esModule, 112 | ); 113 | 114 | try { 115 | const resolvedImages: WebpackResolvedImage[] = []; 116 | const size = await resolveImage( 117 | this.resourcePath, 118 | content, 119 | scalings, 120 | formats, 121 | (file, fileContent) => { 122 | resolvedImages.push( 123 | emitAndResolveImage(this, options, file, fileContent), 124 | ); 125 | }, 126 | ); 127 | 128 | const result = wrapImage(size, resolvedImages); 129 | callback(null, result); 130 | } catch (e) { 131 | callback(e as Error); 132 | } 133 | } 134 | 135 | resolve.raw = true; 136 | export const raw = true; 137 | -------------------------------------------------------------------------------- /packages/web-image-loader/src/options.ts: -------------------------------------------------------------------------------- 1 | import { JSONSchema7 } from 'json-schema'; 2 | 3 | const schema: JSONSchema7 = { 4 | type: 'object', 5 | additionalProperties: false, 6 | properties: { 7 | name: { 8 | description: 'The filename template for the target file(s).', 9 | type: 'string', 10 | }, 11 | imageClassPath: { 12 | description: 13 | 'The path of image class that should be used instead of built-in AdaptiveImage.', 14 | type: 'string', 15 | }, 16 | scalings: { 17 | description: 18 | 'An object where the keys are the possible filename suffixes and values are the amount of scale', 19 | type: 'object', 20 | }, 21 | publicPath: { 22 | description: 'A custom public path for the target file(s).', 23 | anyOf: [ 24 | { 25 | type: 'string', 26 | }, 27 | { 28 | // @ts-ignore 29 | instanceof: 'Function', 30 | }, 31 | ], 32 | }, 33 | outputPath: { 34 | description: 'A filesystem path where the target file(s) will be placed.', 35 | type: 'string', 36 | }, 37 | esModule: { 38 | description: 39 | 'By default, react-native-web-image-loader generates JS modules that use the ES modules syntax.', 40 | type: 'boolean', 41 | }, 42 | formats: { 43 | description: 'Formats to generate.', 44 | type: 'object', 45 | }, 46 | }, 47 | }; 48 | 49 | export default schema; 50 | -------------------------------------------------------------------------------- /packages/web-image-loader/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": ["src/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/web-image/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## [0.3.3](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image@0.3.2...@th3rdwave/web-image@0.3.3) (2024-09-11) 7 | 8 | **Note:** Version bump only for package @th3rdwave/web-image 9 | 10 | 11 | 12 | 13 | 14 | ## [0.3.2](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image@0.3.1...@th3rdwave/web-image@0.3.2) (2021-10-14) 15 | 16 | **Note:** Version bump only for package @th3rdwave/web-image 17 | 18 | 19 | 20 | 21 | 22 | ## [0.3.1](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image@0.3.0...@th3rdwave/web-image@0.3.1) (2021-09-11) 23 | 24 | **Note:** Version bump only for package @th3rdwave/web-image 25 | 26 | 27 | 28 | 29 | 30 | # [0.3.0](https://github.com/th3rdwave/web-image/compare/@th3rdwave/web-image@0.2.4...@th3rdwave/web-image@0.3.0) (2021-09-11) 31 | 32 | **Note:** Version bump only for package @th3rdwave/web-image 33 | 34 | 35 | 36 | 37 | 38 | ## [0.2.4](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.2.3...@th3rdwave/web-image@0.2.4) (2021-08-27) 39 | 40 | **Note:** Version bump only for package @th3rdwave/web-image 41 | 42 | 43 | 44 | 45 | 46 | ## [0.2.3](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.2.2...@th3rdwave/web-image@0.2.3) (2021-07-09) 47 | 48 | **Note:** Version bump only for package @th3rdwave/web-image 49 | 50 | 51 | 52 | 53 | 54 | ## [0.2.2](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.2.1...@th3rdwave/web-image@0.2.2) (2021-07-09) 55 | 56 | **Note:** Version bump only for package @th3rdwave/web-image 57 | 58 | 59 | 60 | 61 | 62 | ## [0.2.1](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.2.0...@th3rdwave/web-image@0.2.1) (2021-06-29) 63 | 64 | **Note:** Version bump only for package @th3rdwave/web-image 65 | 66 | 67 | 68 | 69 | 70 | # [0.2.0](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.1.8...@th3rdwave/web-image@0.2.0) (2021-06-29) 71 | 72 | **Note:** Version bump only for package @th3rdwave/web-image 73 | 74 | 75 | 76 | 77 | 78 | ## [0.1.8](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.1.7...@th3rdwave/web-image@0.1.8) (2021-06-24) 79 | 80 | **Note:** Version bump only for package @th3rdwave/web-image 81 | 82 | 83 | 84 | 85 | 86 | ## [0.1.7](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.1.6...@th3rdwave/web-image@0.1.7) (2021-06-24) 87 | 88 | **Note:** Version bump only for package @th3rdwave/web-image 89 | 90 | 91 | 92 | 93 | 94 | ## [0.1.6](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.1.5...@th3rdwave/web-image@0.1.6) (2020-09-29) 95 | 96 | **Note:** Version bump only for package @th3rdwave/web-image 97 | 98 | 99 | 100 | 101 | 102 | ## [0.1.5](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.1.4...@th3rdwave/web-image@0.1.5) (2020-05-28) 103 | 104 | **Note:** Version bump only for package @th3rdwave/web-image 105 | 106 | 107 | 108 | 109 | 110 | ## [0.1.4](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.1.3...@th3rdwave/web-image@0.1.4) (2020-05-11) 111 | 112 | **Note:** Version bump only for package @th3rdwave/web-image 113 | 114 | 115 | 116 | 117 | 118 | ## [0.1.3](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.1.2...@th3rdwave/web-image@0.1.3) (2020-05-11) 119 | 120 | **Note:** Version bump only for package @th3rdwave/web-image 121 | 122 | 123 | 124 | 125 | 126 | ## [0.1.2](https://github.com/th3rdwave/web-image/tree/master/packages/web-image/compare/@th3rdwave/web-image@0.1.1...@th3rdwave/web-image@0.1.2) (2020-05-11) 127 | 128 | **Note:** Version bump only for package @th3rdwave/web-image 129 | 130 | 131 | 132 | 133 | 134 | ## 0.1.1 (2020-05-11) 135 | 136 | **Note:** Version bump only for package @th3rdwave/web-image 137 | -------------------------------------------------------------------------------- /packages/web-image/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Th3rdwave 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 | -------------------------------------------------------------------------------- /packages/web-image/README.md: -------------------------------------------------------------------------------- 1 | ## Web Image 2 | 3 | TODO 4 | -------------------------------------------------------------------------------- /packages/web-image/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@th3rdwave/web-image", 3 | "version": "0.3.3", 4 | "description": "Modern image component for React Native Web", 5 | "keywords": [ 6 | "react", 7 | "react-native", 8 | "react-native-web", 9 | "web", 10 | "image" 11 | ], 12 | "license": "MIT", 13 | "repository": "https://github.com/th3rdwave/web-image/tree/master/packages/web-image", 14 | "bugs": { 15 | "url": "https://github.com/th3rdwave/web-image/issues" 16 | }, 17 | "homepage": "https://github.com/th3rdwave/web-image#readme", 18 | "scripts": { 19 | "prepare": "bob build" 20 | }, 21 | "main": "lib/commonjs/index.js", 22 | "module": "lib/module/index.js", 23 | "react-native": "src/index.tsx", 24 | "source": "src/index.tsx", 25 | "types": "lib/typescript/src/index.d.ts", 26 | "files": [ 27 | "lib/", 28 | "src/" 29 | ], 30 | "sideEffects": false, 31 | "publishConfig": { 32 | "access": "public" 33 | }, 34 | "peerDependencies": { 35 | "react": "*", 36 | "react-native-web": "*" 37 | }, 38 | "devDependencies": { 39 | "@react-native-community/bob": "^0.13.1", 40 | "@types/react": "^17.0.19", 41 | "@types/react-native": "^0.73.0", 42 | "react": "^16.13.1", 43 | "react-native-web": "^0.12.2", 44 | "typescript": "^4.4.2" 45 | }, 46 | "@react-native-community/bob": { 47 | "source": "src", 48 | "output": "lib", 49 | "targets": [ 50 | "commonjs", 51 | "module", 52 | "typescript" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/web-image/src/Image.tsx: -------------------------------------------------------------------------------- 1 | import type { Property } from 'csstype'; 2 | import * as React from 'react'; 3 | import { 4 | ColorValue, 5 | ImageProps as BaseImageProps, 6 | ImageResizeMode, 7 | ImageStyle, 8 | ImageURISource as BaseImageURISource, 9 | StyleProp, 10 | StyleSheet, 11 | View, 12 | ViewStyle, 13 | } from 'react-native'; 14 | 15 | export interface ResponsiveImageSource { 16 | /** 17 | * A list of one or more strings separated by commas indicating a set of possible images represented by the source for the browser to use. 18 | * 19 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source#attr-srcset 20 | */ 21 | readonly srcSet: string; 22 | /** 23 | * The MIME media type of the resource, optionally with a codecs parameter. 24 | * 25 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source#attr-type 26 | */ 27 | readonly type: string; 28 | /** 29 | * Media query of the resource's intended media. 30 | * 31 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source#attr-media 32 | */ 33 | readonly media?: string | null; 34 | } 35 | 36 | export interface ImageURISource extends BaseImageURISource { 37 | /** 38 | * Responsive image sources. 39 | */ 40 | sources?: readonly ResponsiveImageSource[] | null; 41 | } 42 | 43 | export type ImageSourcePropType = 44 | | ImageURISource 45 | | readonly ImageURISource[] 46 | | number; 47 | 48 | const interopDefault = (source: any): ImageURISource => 49 | typeof source.default === 'object' ? source.default : source; 50 | 51 | const resolveSource = ( 52 | source: ImageSourcePropType, 53 | ): { 54 | uri: string | undefined; 55 | sources: readonly ResponsiveImageSource[] | null | undefined; 56 | width: number | undefined; 57 | height: number | undefined; 58 | } => { 59 | if (source == null || typeof source === 'string') { 60 | return { 61 | uri: source, 62 | sources: undefined, 63 | width: undefined, 64 | height: undefined, 65 | }; 66 | } else if (typeof source === 'object') { 67 | const sourceObject = interopDefault(source); 68 | const width = 69 | typeof sourceObject.width === 'number' ? sourceObject.width : undefined; 70 | const height = 71 | typeof sourceObject.height === 'number' ? sourceObject.height : undefined; 72 | return { 73 | uri: sourceObject.uri, 74 | sources: sourceObject.sources, 75 | width, 76 | height, 77 | }; 78 | } else { 79 | return { 80 | uri: undefined, 81 | sources: undefined, 82 | width: undefined, 83 | height: undefined, 84 | }; 85 | } 86 | }; 87 | 88 | const resizeModeToObjectFit = ( 89 | resizeMode: ImageResizeMode, 90 | ): Property.ObjectFit => { 91 | switch (resizeMode) { 92 | case 'cover': 93 | return 'cover'; 94 | case 'contain': 95 | return 'contain'; 96 | case 'stretch': 97 | return 'fill'; 98 | case 'center': 99 | return 'none'; 100 | default: 101 | throw new Error('Unsupported resize mode: ' + resizeMode); 102 | } 103 | }; 104 | 105 | let _filterId = 0; 106 | 107 | // https://github.com/necolas/react-native-web/blob/master/packages/react-native-web/src/exports/Image/index.js 108 | const createTintColorSVG = ( 109 | tintColor: ColorValue | undefined, 110 | id: number | null | undefined, 111 | ) => { 112 | return tintColor != null && id != null ? ( 113 | 121 | 122 | {/* @ts-expect-error */} 123 | 124 | 128 | 129 | 130 | 131 | 132 | ) : null; 133 | }; 134 | 135 | const getFlatStyle = ( 136 | style: StyleProp | null | undefined, 137 | blurRadius: number | null | undefined, 138 | propsTintColor: ColorValue | undefined, 139 | filterId: number | null | undefined, 140 | ): [ 141 | ViewStyle, 142 | ImageStyle['resizeMode'], 143 | Property.Filter | undefined, 144 | ColorValue | undefined, 145 | ] => { 146 | const flatStyle = { ...StyleSheet.flatten(style) }; 147 | const { filter, resizeMode } = flatStyle as any; 148 | const tintColor = flatStyle.tintColor ?? propsTintColor; 149 | 150 | // Add CSS filters 151 | // React Native exposes these features as props and proprietary styles 152 | const filters: string[] = []; 153 | let _filter: string | undefined; 154 | 155 | if (filter) { 156 | filters.push(filter); 157 | } 158 | if (blurRadius) { 159 | filters.push(`blur(${blurRadius}px)`); 160 | } 161 | if (tintColor && filterId != null) { 162 | filters.push(`url(#tint-${filterId})`); 163 | } 164 | 165 | if (filters.length > 0) { 166 | _filter = filters.join(' '); 167 | } 168 | 169 | // These styles are converted to CSS filters applied to the 170 | // element displaying the background image. 171 | delete flatStyle.tintColor; 172 | // These styles are not supported on View 173 | delete flatStyle.overlayColor; 174 | delete flatStyle.resizeMode; 175 | 176 | return [flatStyle, resizeMode, _filter, tintColor]; 177 | }; 178 | 179 | export interface ImageProps extends Omit { 180 | /** 181 | * If the image should not be lazy loaded. 182 | * 183 | * @platform web 184 | */ 185 | critical?: boolean; 186 | /** 187 | * If the image is draggable. 188 | * 189 | * @platform web 190 | */ 191 | draggable?: boolean; 192 | source: ImageSourcePropType; 193 | tintColor?: ColorValue; 194 | } 195 | 196 | export const Image = React.forwardRef( 197 | ( 198 | { 199 | source, 200 | resizeMode: propsResizeMode, 201 | accessibilityLabel, 202 | 'aria-label': ariaLabel, 203 | alt, 204 | width, 205 | height, 206 | critical, 207 | style, 208 | draggable, 209 | tintColor: propsTintColor, 210 | blurRadius, 211 | fadeDuration = 300, 212 | onLoadEnd, 213 | ...others 214 | }, 215 | ref, 216 | ) => { 217 | const filterRef = React.useRef(_filterId++); 218 | const resolvedSource = resolveSource(source); 219 | const [flatStyle, _resizeMode, filter, tintColor] = getFlatStyle( 220 | style, 221 | blurRadius, 222 | propsTintColor, 223 | filterRef.current, 224 | ); 225 | const resizeMode = propsResizeMode ?? _resizeMode; 226 | 227 | const [loaded, setLoaded] = React.useState( 228 | fadeDuration === 0 ? true : null, 229 | ); 230 | 231 | // Avoid fade effect if the image is cached or loaded very fast 232 | // using arbitrary 50ms. There doesn't seem to be a way to know 233 | // when an image starts loading using the dom api and loading="lazy" 234 | // so just assume it starts when the component mounts, which is true 235 | // for images initially on screen. For the other ones fading them in 236 | // as they come on streen should always be a desired effect. 237 | const imgRef = React.useRef(null); 238 | const timeoutRef = React.useRef(null); 239 | React.useEffect(() => { 240 | if (fadeDuration !== 0) { 241 | timeoutRef.current = setTimeout(() => { 242 | if (imgRef.current != null && !imgRef.current.complete) { 243 | setLoaded(false); 244 | } 245 | }, 50); 246 | } 247 | return () => { 248 | clearTimeout(timeoutRef.current); 249 | }; 250 | // eslint-disable-next-line react-hooks/exhaustive-deps 251 | }, []); 252 | 253 | // For SSR it is possible the image loads before react can attach event 254 | // handles so check on mount if image has loaded already. 255 | const hasCalledInitialLoad = React.useRef(false); 256 | React.useEffect(() => { 257 | if ( 258 | imgRef.current != null && 259 | imgRef.current.complete && 260 | !hasCalledInitialLoad.current 261 | ) { 262 | onLoadEnd?.(); 263 | } 264 | // eslint-disable-next-line react-hooks/exhaustive-deps 265 | }, []); 266 | 267 | const onLoad = () => { 268 | hasCalledInitialLoad.current = true; 269 | clearTimeout(timeoutRef.current); 270 | setLoaded(true); 271 | onLoadEnd?.(); 272 | }; 273 | 274 | return ( 275 | 290 | {resolvedSource.width != null && resolvedSource.height != null && ( 291 |
298 | )} 299 | {source != null && ( 300 | 309 | {resolvedSource.sources?.map((s: any, i: number) => ( 310 | 317 | ))} 318 | {alt 346 | 347 | )} 348 | {createTintColorSVG(tintColor, filterRef.current)} 349 | 350 | ); 351 | }, 352 | ); 353 | -------------------------------------------------------------------------------- /packages/web-image/src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './Image'; 2 | -------------------------------------------------------------------------------- /packages/web-image/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "include": [ 4 | "src/**/*" 5 | ], 6 | "compilerOptions": { 7 | "jsx": "react", 8 | "lib": [ 9 | "esnext", 10 | "dom" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "incremental": true, 5 | "noEmit": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": false, 9 | "emitDeclarationOnly": true, 10 | "outDir": "./ts-lib", 11 | 12 | "allowUnreachableCode": false, 13 | "allowUnusedLabels": false, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "jsx": "react", 17 | "lib": ["esnext"], 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | "noFallthroughCasesInSwitch": true, 21 | "noImplicitReturns": true, 22 | "noImplicitUseStrict": false, 23 | "noStrictGenericChecks": false, 24 | "noUnusedLocals": true, 25 | "noUnusedParameters": true, 26 | "resolveJsonModule": true, 27 | "skipLibCheck": true, 28 | "strict": true, 29 | "target": "esnext" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "include": [], 4 | "compilerOptions": { "noEmit": true }, 5 | "references": [ 6 | { "path": "./packages/web-image" }, 7 | { "path": "./packages/web-image-loader" } 8 | ] 9 | } 10 | --------------------------------------------------------------------------------