diff --git a/example/README.md b/example/README.md index f981602..b20b37d 100644 --- a/example/README.md +++ b/example/README.md @@ -3,5 +3,5 @@ Чтобы запустить этот пример, нужно предварительно запустить imgproxy локально на 8080 порту командой `docker run -e IMGPROXY_MAX_SRC_RESOLUTION=40 -p 8080:8080 -it darthsim/imgproxy`, -затем выполнить `npm run run-example` из корня проекта +затем выполнить `npm run dev` из корня проекта и открыть в браузере http://localhost:8081/. diff --git a/example/react-entry.tsx b/example/react-entry.tsx index bd3d2ae..4bbfa6c 100644 --- a/example/react-entry.tsx +++ b/example/react-entry.tsx @@ -2,35 +2,58 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { PictureSmart } from '../src/react'; import { backgroundCssSmart } from '../src/utils/backgroundCss'; +import { getOriginal } from '../src/utils'; // Adds 'webp' class to html element if the browser supports webp. -import '../src/utils/webpDetector' +import '../src/utils/webpDetector'; const oneImageForAllBreakpoints = require.context('./images/oneImageForAllBreakpoints'); const differentBreakpoints = require.context('./images/differentBreakpoints'); +const imageWithoutResize = require.context('./images/oneImageForAllBreakpoints?dontresize'); + +const myImageData = require('./images/oneImageForAllBreakpoints/all.png'); +const myImageWithoutResizeData = require('./images/oneImageForAllBreakpoints/all.png?dontresize'); + +// Usage example getOriginal () +console.log(getOriginal(myImageData)); +console.log(getOriginal(myImageWithoutResizeData)); + ReactDOM.render(

Example usage of csssr.images

As picture tag

+

One image for all resolutions

+

Image with different breakpoints

+

Image without resize

+

As background css

+

One image for all resolutions

One image for all resolutions on background
+

Different breakpoints

Image with different breakpoints on background
+ +

Image without resize

+ +
+ Image without resize on background +
+
, document.getElementById('app'), ); diff --git a/example/webpack.config.ts b/example/webpack.config.ts index de0be20..5b96552 100644 --- a/example/webpack.config.ts +++ b/example/webpack.config.ts @@ -1,6 +1,52 @@ import path from 'path'; import webpack from 'webpack'; +import ip from 'ip'; import { Plugin } from '../src/webpack'; +import { Dpr } from '../src/types'; + +const handleImagesForOriginalPixelRatio = (originalPixelRatio: Dpr) => { + return { + use: [ + { + loader: path.resolve(__dirname, '../src/index.ts'), + options: { + breakpoints: [ + { + name: 'mobile', + maxWidth: 767, + }, + { + name: 'tablet', + minWidth: 768, + maxWidth: 1279, + }, + { + name: 'desktop', + minWidth: 1280, + }, + ], + imgproxy: { + disable: false, + imagesHost: process.env.HOST || `http://${ip.address()}:8081`, + host: process.env.IMGPROXY_HOST || 'http://localhost:8080', + }, + originalPixelRatio, + }, + }, + { + loader: 'file-loader', + options: { + publicPath: '/build', + name: + process.env.NODE_ENV === 'development' + ? '[path][name].[ext]' + : '[path][name]-[hash:8].[ext]', + esModule: false, + }, + }, + ], + }; +}; const config: webpack.Configuration = { mode: 'production', @@ -23,42 +69,13 @@ const config: webpack.Configuration = { }, { test: /\.(jpe?g|png|gif)$/, - use: [ + oneOf: [ { - loader: path.resolve(__dirname, '../src/index.ts'), - options: { - breakpoints: [ - { - name: 'mobile', - maxWidth: 767, - }, - { - name: 'tablet', - minWidth: 768, - maxWidth: 1279, - }, - { - name: 'desktop', - minWidth: 1280, - }, - ], - imgproxy: { - disable: false, - imagesHost: process.env.HOST || 'http://192.168.1.134:8081', - host: process.env.IMGPROXY_HOST || 'http://localhost:8080', - }, - }, + resourceQuery: /dontresize/, + ...handleImagesForOriginalPixelRatio('1x'), }, { - loader: 'file-loader', - options: { - publicPath: '/build', - name: - process.env.NODE_ENV === 'development' - ? '[path][name].[ext]' - : '[path][name]-[hash:8].[ext]', - esModule: false, - }, + ...handleImagesForOriginalPixelRatio('3x'), }, ], }, diff --git a/package-lock.json b/package-lock.json index 171267a..f9fb08a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1181,6 +1181,14 @@ "@types/node": "*" } }, + "@types/ip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@types/ip/-/ip-1.1.0.tgz", + "integrity": "sha512-dwNe8gOoF70VdL6WJBwVHtQmAX4RMd62M+mAB9HQFjG1/qiCLM/meRy95Pd14FYBbEDwCq7jgJs89cHpLBu4HQ==", + "requires": { + "@types/node": "*" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.2.tgz", @@ -1235,8 +1243,7 @@ "@types/node": { "version": "14.0.23", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.0.23.tgz", - "integrity": "sha512-Z4U8yDAl5TFkmYsZdFPdjeMa57NOvnaf1tljHzhouaPEp7LCj2JKkejpI1ODviIAQuW4CcQmxkQ77rnLsOOoKw==", - "dev": true + "integrity": "sha512-Z4U8yDAl5TFkmYsZdFPdjeMa57NOvnaf1tljHzhouaPEp7LCj2JKkejpI1ODviIAQuW4CcQmxkQ77rnLsOOoKw==" }, "@types/normalize-package-data": { "version": "2.4.0", @@ -4728,6 +4735,12 @@ "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", "dev": true }, + "ip": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz", + "integrity": "sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=", + "dev": true + }, "ip-regex": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz", diff --git a/package.json b/package.json index 2e9e2e8..cfcfd64 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "eslint-plugin-react": "^7.20.3", "file-loader": "^6.0.0", "http-server": "^0.12.3", - "jest": "^26.1.0", + "ip": "^1.1.5", + "jest": "^26.0.1", "loader-utils": "^2.0.0", "prettier": "^2.0.5", "ts-jest": "^26.1.3", @@ -49,6 +50,7 @@ "webpack-cli": "^3.3.12" }, "dependencies": { + "@types/ip": "^1.1.0", "imgproxy": "^0.1.2", "react": "^16.13.1", "react-dom": "^16.13.1" diff --git a/src/types.ts b/src/types.ts index 90fcd8a..68b2552 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,15 @@ export type Dpr = '1x' | '2x' | '3x'; +export type CompressionRatio = { + '1x': number; + '2x'?: number; + '3x'?: number; +}; + export type SrcSet = { - [dpr in Dpr]: string; + '1x': string; + '2x'?: string; + '3x'?: string; }; export type ExtensionSrcSet = { diff --git a/src/utils/__tests__/backgroundCss.ts b/src/utils/__tests__/backgroundCss.ts index ca6c704..3ca4b86 100644 --- a/src/utils/__tests__/backgroundCss.ts +++ b/src/utils/__tests__/backgroundCss.ts @@ -397,3 +397,34 @@ test('backgroundCss multiple breakpoints, png and webp', () => { } }`); }); + +test('backgroundCss all breakpoints, png and webp, one pixel ratio', () => { + expect( + backgroundCss('.my-selector', [ + { + breakpointMedia: null, + srcSets: [ + { + extension: 'png', + srcSet: { + '1x': '/mobile.all.1x.png', + }, + }, + { + extension: 'webp', + srcSet: { + '1x': '/mobile.all.1x.webp', + }, + }, + ], + }, + ]), + ).toBeSameCss(` + .my-selector { + background-image: url(/mobile.all.1x.png); + } + html.webp .my-selector { + background-image: url(/mobile.all.1x.webp); + } +`); +}); diff --git a/src/utils/__tests__/getCompressionRatio.ts b/src/utils/__tests__/getCompressionRatio.ts new file mode 100644 index 0000000..591e5ef --- /dev/null +++ b/src/utils/__tests__/getCompressionRatio.ts @@ -0,0 +1,17 @@ +import { getCompressionRatio } from '../index'; + +test('Pixel ratios 1x', () => { + expect(getCompressionRatio(['1x'])).toStrictEqual({ '1x': 0 }); +}); + +test('Pixel ratios 1x, 2x', () => { + expect(getCompressionRatio(['1x', '2x'])).toStrictEqual({ '1x': 0.5, '2x': 0 }); +}); + +test('Pixel ratios 1x, 2x, 3x', () => { + expect(getCompressionRatio(['1x', '2x', '3x'])).toStrictEqual({ + '1x': 0.33333, + '2x': 0.66667, + '3x': 0, + }); +}); diff --git a/src/utils/backgroundCss.ts b/src/utils/backgroundCss.ts index d492e3a..19e04dd 100644 --- a/src/utils/backgroundCss.ts +++ b/src/utils/backgroundCss.ts @@ -17,17 +17,31 @@ const srcSetCss = (selector: string, sources: ExtensionSrcSet[]): string => { (acc, source) => { const finalSelector = getSelector(selector, source.extension); acc['1x'].push(`${finalSelector} { background-image: url(${source.srcSet['1x']}); }`); - acc['2x'].push(`${finalSelector} { background-image: url(${source.srcSet['2x']}); }`); - acc['3x'].push(`${finalSelector} { background-image: url(${source.srcSet['3x']}); }`); + source.srcSet['2x'] && + acc['2x'].push(`${finalSelector} { background-image: url(${source.srcSet['2x']}); }`); + source.srcSet['3x'] && + acc['3x'].push(`${finalSelector} { background-image: url(${source.srcSet['3x']}); }`); return acc; }, { '1x': [], '2x': [], '3x': [] }, ); return ` -${result['1x'].join(' ')} -@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { ${result['2x'].join(' ')} } -@media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 288dpi) { ${result['3x'].join(' ')} } + ${result['1x'].join(' ')} + ${ + result['2x'].length + ? `@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) { ${result['2x'].join( + ' ', + )} }` + : '' + } + ${ + result['3x'].length + ? ` @media (-webkit-min-device-pixel-ratio: 3), (min-resolution: 288dpi) { ${result[ + '3x' + ].join(' ')} }` + : '' + } `; }; diff --git a/src/utils/getCompressionRatio.ts b/src/utils/getCompressionRatio.ts new file mode 100644 index 0000000..cc122db --- /dev/null +++ b/src/utils/getCompressionRatio.ts @@ -0,0 +1,15 @@ +import { Dpr, CompressionRatio } from '../types'; + +export const getCompressionRatio = (pixelRatios: Dpr[]): CompressionRatio => { + const length = pixelRatios.length; + + return pixelRatios.reduce((acc, item, index) => { + if (index + 1 === length) { + acc[item] = 0; + return acc; + } + + acc[item] = Number(((index + 1) / length).toFixed(5)); + return acc; + }, {} as CompressionRatio); +}; diff --git a/src/utils/getOriginal.ts b/src/utils/getOriginal.ts index c904e46..03fa628 100644 --- a/src/utils/getOriginal.ts +++ b/src/utils/getOriginal.ts @@ -1,4 +1,7 @@ import { BreakpointSource } from '../types'; -export const getOriginal = (source: BreakpointSource): string => - source.srcSets[source.srcSets.length - 1].srcSet['3x']; +export const getOriginal = (source: BreakpointSource): string => { + const srcSet = source.srcSets[source.srcSets.length - 1].srcSet; + + return srcSet['3x'] || srcSet['2x'] || srcSet['1x']; +}; diff --git a/src/utils/getPixelRatios.ts b/src/utils/getPixelRatios.ts new file mode 100644 index 0000000..0d4dacd --- /dev/null +++ b/src/utils/getPixelRatios.ts @@ -0,0 +1,12 @@ +import { Dpr } from '../types'; + +export const getPixelRatios = (originalPixelRatio: Dpr): Dpr[] => { + switch (originalPixelRatio) { + case '1x': + return ['1x']; + case '2x': + return ['1x', '2x']; + case '3x': + return ['1x', '2x', '3x']; + } +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index ed31173..9f6e826 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,3 +3,5 @@ export { getBreakpointMedia } from './getBreakpointMedia'; export { getSrcSetString } from './getSrcSetString'; export { getSources } from './getSources'; export { getOriginal } from './getOriginal'; +export { getPixelRatios } from './getPixelRatios'; +export { getCompressionRatio } from './getCompressionRatio'; diff --git a/src/utils/webpDetector.ts b/src/utils/webpDetector.ts index 43282ba..6cdabe8 100644 --- a/src/utils/webpDetector.ts +++ b/src/utils/webpDetector.ts @@ -1,13 +1,15 @@ // https://stackoverflow.com/a/27232658 const canUseWebp = (): boolean => { - const canvas = document.createElement('canvas') - canvas.width = canvas.height = 1 - return Boolean(canvas.toDataURL && - canvas.toDataURL('image/webp') && - canvas.toDataURL('image/webp').indexOf('image/webp') === 5) -} + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + return Boolean( + canvas.toDataURL && + canvas.toDataURL('image/webp') && + canvas.toDataURL('image/webp').indexOf('image/webp') === 5, + ); +}; if (canUseWebp()) { - document.documentElement.classList.add('webp') + document.documentElement.classList.add('webp'); } diff --git a/src/webpack/imgproxyUrlBuilder.ts b/src/webpack/imgproxyUrlBuilder.ts index b93e055..f0dd5fc 100644 --- a/src/webpack/imgproxyUrlBuilder.ts +++ b/src/webpack/imgproxyUrlBuilder.ts @@ -1,17 +1,22 @@ import Imgproxy from 'imgproxy'; -import { SrcSet } from '../types'; +import { Dpr, SrcSet } from '../types'; +import { getCompressionRatio } from '../utils'; type ImgproxyUrlBuilderConfig = { imagesHost: string; host: string; }; -export type BuildUrlsForAllPixelRatios = (imagePath: string, extension: string) => SrcSet; +export type BuildUrlsForPixelRatios = ( + pixelRatios: Dpr[], + imagePath: string, + extension: string, +) => SrcSet; export const getImgproxyUrlBuilder = ({ imagesHost, host, -}: ImgproxyUrlBuilderConfig): BuildUrlsForAllPixelRatios => { +}: ImgproxyUrlBuilderConfig): BuildUrlsForPixelRatios => { const imgproxy = new Imgproxy({ baseUrl: host, encode: false, @@ -24,10 +29,15 @@ export const getImgproxyUrlBuilder = ({ .generateUrl(imagesHost + imgPath, extension); }; - return (imagePath: string, extension: string): SrcSet => ({ - '1x': buildImgproxyUrl(imagePath, 0.3333, extension), - '2x': buildImgproxyUrl(imagePath, 0.6666, extension), - // 0 здесь означает, что не будет никакого изменения размеров картинки - '3x': buildImgproxyUrl(imagePath, 0, extension), - }); + return (pixelRatios: Dpr[], imagePath: string, extension: string): SrcSet => { + const compressionsRatio = getCompressionRatio(pixelRatios); + + return pixelRatios.reduce((acc, item) => { + const dprResize = compressionsRatio[item]; + if (dprResize !== undefined) { + acc[item] = buildImgproxyUrl(imagePath, dprResize, extension); + } + return acc; + }, {} as SrcSet); + }; }; diff --git a/src/webpack/loader.ts b/src/webpack/loader.ts index deccada..28ff2c9 100644 --- a/src/webpack/loader.ts +++ b/src/webpack/loader.ts @@ -3,10 +3,10 @@ import path from 'path'; import loaderUtils from 'loader-utils'; import validateOptions from 'schema-utils'; import { getImgproxyUrlBuilder } from './imgproxyUrlBuilder'; -import { Breakpoint, OrderedBreakpointSource, SrcSet } from '../types'; +import { Breakpoint, OrderedBreakpointSource, SrcSet, Dpr } from '../types'; import { imageUrls } from './plugin'; import { schema } from './loaderOptionsSchema'; -import { getBreakpointMedia } from '../utils'; +import { getBreakpointMedia, getPixelRatios } from '../utils'; // Такое имя используется, если нужна одна картинка для всех разрешений // В таком случаем не будут сгенерированы медиа выражения для разных breakpoint'ов @@ -19,18 +19,19 @@ export type LoaderOptions = { imagesHost: string; host: string; }; + originalPixelRatio: Dpr; }; // Каждый импорт картинки проходит через этот лоадер и на выходе // для каждой картинки получится массив с двумя значениями – // srcset'ы для webp и srcset для оригинального расширения изображения export const loader = function (this: webpack.loader.LoaderContext, source: string): string { - const options = loaderUtils.getOptions(this) as unknown as LoaderOptions; + const options = (loaderUtils.getOptions(this) as unknown) as LoaderOptions; validateOptions(schema, options, { name: 'Imgproxy responsive loader', baseDataPath: 'options' }); + const pixelRatios: Dpr[] = getPixelRatios(options.originalPixelRatio); const breakpoints: Breakpoint[] = options.breakpoints; - // Такой результат приходит от file-loader 'module.exports = "/build/myImage/mobile.all-4b767a7b.png";' // Получаем оригинальное имя файла изображения (originalImageFileName = mobile.all.png) const originalImageFileName = path.relative(this.context, this.resourcePath); @@ -61,30 +62,36 @@ export const loader = function (this: webpack.loader.LoaderContext, source: stri const breakpointMedia = breakpointName === all ? null : getBreakpointMedia(breakpoints[order]); // Получаем путь до картинки (outputImagePath = '/build/myImage/mobile.all-4b767a7b.png') - const outputImagePath = source.replace(/^module.exports = (__webpack_public_path__ \+ )?"(.+)";$/, (a, b, imagePath) => imagePath); + const outputImagePath = source.replace( + /^module.exports = (__webpack_public_path__ \+ )?"(.+)";$/, + (a, b, imagePath) => imagePath, + ); let webpSrcSet: SrcSet, originalExtensionSrcSet: SrcSet, data: OrderedBreakpointSource; // Отключает процессинг картинок, генерируется srcSet только для оригинального типа изображения if (options.imgproxy.disable) { - originalExtensionSrcSet = { - '1x': outputImagePath, - '2x': outputImagePath, - '3x': outputImagePath, - }; data = { order, breakpointMedia, srcSets: [ { extension: originalExtension, - srcSet: originalExtensionSrcSet, + srcSet: pixelRatios.reduce((acc, item): SrcSet => { + acc[item] = outputImagePath; + return acc; + }, {} as SrcSet), }, ], }; } else { - const buildUrlsForAllPixelRatios = getImgproxyUrlBuilder(options.imgproxy); - webpSrcSet = buildUrlsForAllPixelRatios(outputImagePath, 'webp'); - originalExtensionSrcSet = buildUrlsForAllPixelRatios(outputImagePath, originalExtension); + const buildUrlsForPixelRatios = getImgproxyUrlBuilder(options.imgproxy); + webpSrcSet = buildUrlsForPixelRatios(pixelRatios, outputImagePath, 'webp'); + originalExtensionSrcSet = buildUrlsForPixelRatios( + pixelRatios, + outputImagePath, + originalExtension, + ); + data = { order, breakpointMedia, @@ -100,7 +107,10 @@ export const loader = function (this: webpack.loader.LoaderContext, source: stri ], }; // Добавляем ссылки на картинки через imgproxy в глобальный объект - imageUrls.push(...Object.values(webpSrcSet), ...Object.values(originalExtensionSrcSet)); + imageUrls.push( + ...(Object.values(webpSrcSet) as string[]), + ...(Object.values(originalExtensionSrcSet) as string[]), + ); } const result: OrderedBreakpointSource = data; diff --git a/src/webpack/loaderOptionsSchema.ts b/src/webpack/loaderOptionsSchema.ts index 94619b8..c3c8326 100644 --- a/src/webpack/loaderOptionsSchema.ts +++ b/src/webpack/loaderOptionsSchema.ts @@ -48,7 +48,11 @@ export const schema: JSONSchema7 = { }, ], }, + originalPixelRatio: { + type: 'string', + pattern: '^(1x|2x|3x)$', + }, }, - required: ['breakpoints', 'imgproxy'], + required: ['breakpoints', 'imgproxy', 'originalPixelRatio'], additionalProperties: false, };