diff --git a/CHANGELOG.md b/CHANGELOG.md
index 372b8b7453..49fd0e1446 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -729,6 +729,7 @@ See https://docs.voltocms.com/upgrade-guide/ for more information about all the
### Feature
+- Add Image with srcset and lazy loading using Plone scales @nzambello
- Add runtime configuration for `@babel/plugin-transform-react-jsx` set to `automatic`. This enables the new JSX runtime: https://reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html So no longer `import React from 'react'` is needed anymore.
- Update favicon and related tags with best practices @sneridagh
diff --git a/cypress/tests/core/blocks-image.js b/cypress/tests/core/blocks-image.js
index 4c722b506f..b43a3b4141 100644
--- a/cypress/tests/core/blocks-image.js
+++ b/cypress/tests/core/blocks-image.js
@@ -113,7 +113,7 @@ describe('Blocks Tests', () => {
// then image src must be equal to image name
cy.get('.block img')
.should('have.attr', 'src')
- .and('eq', '/my-page/image.png/@@images/image');
+ .and('eq', '/my-page/image.png/@@images/image/icon');
cy.get('.block img')
.should('be.visible')
diff --git a/cypress/tests/core/blocks-listing-templates.js b/cypress/tests/core/blocks-listing-templates.js
index 245bab3bec..50f78be339 100644
--- a/cypress/tests/core/blocks-listing-templates.js
+++ b/cypress/tests/core/blocks-listing-templates.js
@@ -48,7 +48,7 @@ describe('Folder Contents Tests', () => {
cy.url().should('eq', Cypress.config().baseUrl + '/my-folder/my-document');
cy.get('.listing-item img')
.should('have.attr', 'src')
- .and('contain', '/my-folder/my-document/my-image/@@images/image/preview');
+ .and('contain', '/my-folder/my-document/my-image/@@images/image/icon');
cy.get('.listing-item img')
.should('be.visible')
.and(($img) => {
diff --git a/cypress/tests/core/objectBrowser.js b/cypress/tests/core/objectBrowser.js
index 58a7b0c444..6c9b78558d 100644
--- a/cypress/tests/core/objectBrowser.js
+++ b/cypress/tests/core/objectBrowser.js
@@ -44,7 +44,7 @@ describe('Object Browser Tests', () => {
// then we should see a image
cy.get('.block img')
.should('have.attr', 'src')
- .and('eq', '/my-page-1/my-image/@@images/image');
+ .and('eq', '/my-page-1/my-image/@@images/image/icon');
});
it('As editor I can add the full url in search box in sidebar', () => {
@@ -61,7 +61,7 @@ describe('Object Browser Tests', () => {
// then we should see a image
cy.get('.block img')
.should('have.attr', 'src')
- .and('eq', '/my-page-1/my-image/@@images/image');
+ .and('eq', '/my-page-1/my-image/@@images/image/icon');
});
it('As editor I get focus on search box in sidebar when clicking on lens icon', () => {
diff --git a/docs/source/developer-guidelines/images.md b/docs/source/developer-guidelines/images.md
new file mode 100644
index 0000000000..e081bdd4d8
--- /dev/null
+++ b/docs/source/developer-guidelines/images.md
@@ -0,0 +1,65 @@
+# Images
+
+Images are rendered with the Volto component `Image`.
+It renders images using Plone scales from [plone.app.imaging](https://github.com/plone/plone.app.imaging) and handles:
+
+- Lazy load of images
+- Loads the most correct one for current image size with `srcset`
+
+## Usage
+
+Basic example:
+
+```jsx
+import Image from '@plone/volto/components/theme/Image/Image';
+
+
-
+
+
+
{item.description}
diff --git a/src/components/theme/Image/Image.jsx b/src/components/theme/Image/Image.jsx new file mode 100644 index 0000000000..cba292b6fe --- /dev/null +++ b/src/components/theme/Image/Image.jsx @@ -0,0 +1,181 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { getImageAttributes } from '@plone/volto/helpers'; + +/** + * Image component + * @param {object | string} image - Plone image as object or url + * @param {string} imageField - (default: image) image field for scales URL + * @param {string} alt - Alternative text for image + * @param {string} className - CSS class attribute + * @param {string} containerClassName - CSS class attribute for picture element + * @param {string} floated - float left or right + * @param {string} responsive - if the image is responsive + * @param {string} size - (css class) actual width: thumb, small, medium or large + * @param {string} role - img role attribute + * @param {boolean} critical - if critical, do not lazy load the image + * @param {number} maxSize - maximum size to render + * @param {boolean} useOriginal - whether to render original size + */ +const Image = ({ + image, + imageField = 'image', + alt = '', + className, + containerClassName, + floated, + size, + responsive = true, + role = 'img', + critical = false, + maxSize, + minSize = 0, + useOriginal = false, + sizes = '100vw', + ...imageProps +}) => { + const { src, srcSet, width, height, aspectRatio } = getImageAttributes( + image, + { + imageField, + maxSize, + useOriginal, + minSize, + }, + ); + const imageRef = useRef(); + const [srcset, setSrcset] = useState( + critical && srcSet ? srcSet.join(', ') : null, + ); + const imageHasLoaded = imageRef?.current?.complete; + + //picture classname + let pictureClassName = `volto-image${ + containerClassName ? ` ${containerClassName}` : '' + }`; + if (floated) { + pictureClassName = `${pictureClassName} floated ${floated}`; + } + if (size) { + pictureClassName = `${pictureClassName} ${size}`; + } + + if (responsive) { + pictureClassName = `${pictureClassName} responsive`; + } + + //apply srcset + const applySrcSet = useCallback(() => { + setSrcset( + srcSet + .filter((s, index) => { + let addable = (ss) => { + let devicePixelRatio = window.devicePixelRatio; + + let w = ss ? parseInt(ss.split(' ')[1].replace('w', ''), 10) : null; + + return w + ? w <= + (imageRef?.current?.width * devicePixelRatio ?? Infinity) || + w <= + (imageRef?.current?.height * devicePixelRatio ?? Infinity) + : false; + }; + + let add = addable(s); + + if (!add && addable(srcSet[index - 1])) { + add = true; //add the next item grather then imageRef width, to avoid less quality + } + + return add; + }) + .join(', '), + ); + }, [srcSet]); + + //intersection observer + useEffect(() => { + if ('IntersectionObserver' in window && !srcset) { + const observer = new IntersectionObserver( + (entries) => { + setTimeout(() => { + if ( + entries[0].isIntersecting === true && + //imageRef?.current?.complete && //removed to load images on top of the page. + (!srcset || srcset?.split(', ')?.length < 2) && + srcSet?.length > 0 + ) { + applySrcSet(); + } + }, 10); + }, + { threshold: [0], rootMargin: '100px' }, + ); + observer.observe(imageRef.current); + } else if (srcSet?.length > 0) { + applySrcSet(); + } + }, [imageRef, applySrcSet, imageHasLoaded, srcSet, srcset]); + + return ( + <> +{item.description}
diff --git a/src/components/theme/View/AlbumView.test.jsx b/src/components/theme/View/AlbumView.test.jsx index 3fcca46570..c93ead9d56 100644 --- a/src/components/theme/View/AlbumView.test.jsx +++ b/src/components/theme/View/AlbumView.test.jsx @@ -2,6 +2,18 @@ import React from 'react'; import renderer from 'react-test-renderer'; import AlbumView from './AlbumView'; +import config from '@plone/volto/registry'; + +config.settings.imageScales = { + large: 768, + preview: 400, + mini: 200, + thumb: 128, + tile: 64, + icon: 32, + listing: 16, +}; + test('renders an gallery view component', () => { const component = renderer.create({item.description}
} - {item.image && ( + {item.image_field && ({item.description}
} diff --git a/src/components/theme/View/SummaryView.test.jsx b/src/components/theme/View/SummaryView.test.jsx index 41d33c6577..ed5634d1b6 100644 --- a/src/components/theme/View/SummaryView.test.jsx +++ b/src/components/theme/View/SummaryView.test.jsx @@ -3,9 +3,20 @@ import renderer from 'react-test-renderer'; import configureStore from 'redux-mock-store'; import { Provider } from 'react-intl-redux'; import { MemoryRouter } from 'react-router-dom'; - import SummaryView from './SummaryView'; +import config from '@plone/volto/registry'; + +config.settings.imageScales = { + large: 768, + preview: 400, + mini: 200, + thumb: 128, + tile: 64, + icon: 32, + listing: 16, +}; + const mockStore = configureStore(); describe('TabularView', () => { @@ -25,10 +36,13 @@ describe('TabularView', () => { description: 'Hi', items: [ { + '@id': 'http://localhost:3000/my-item', + '@type': 'News Item', title: 'My item', description: 'My item description', url: '/item', image: { + download: 'file:///preview.jpg', scales: { thumb: { download: 'file:///preview.jpg', @@ -36,7 +50,7 @@ describe('TabularView', () => { }, }, image_caption: 'My image caption', - '@type': 'News Item', + image_field: 'image', }, ], }} diff --git a/src/components/theme/View/__snapshots__/AlbumView.test.jsx.snap b/src/components/theme/View/__snapshots__/AlbumView.test.jsx.snap index c77e1c982c..a40c4e4da2 100644 --- a/src/components/theme/View/__snapshots__/AlbumView.test.jsx.snap +++ b/src/components/theme/View/__snapshots__/AlbumView.test.jsx.snap @@ -36,10 +36,34 @@ exports[`renders an gallery view component 1`] = `My item description diff --git a/src/config/ImageScales.jsx b/src/config/ImageScales.jsx new file mode 100644 index 0000000000..1b483f305d --- /dev/null +++ b/src/config/ImageScales.jsx @@ -0,0 +1,12 @@ +export const imageScales = { + huge: 1600, + great: 1200, + larger: 1000, + large: 800, + teaser: 600, + preview: 400, + mini: 200, + thumb: 128, + tile: 64, + icon: 32, +}; diff --git a/src/config/index.js b/src/config/index.js index 0e11f6d1fa..3d8b6c573f 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -23,6 +23,7 @@ import { loadables } from './Loadables'; import { sentryOptions } from './Sentry'; import { contentIcons } from './ContentIcons'; +import { imageScales } from './ImageScales'; import { controlPanelsIcons } from './ControlPanels'; import { richtextEditorSettings, richtextViewSettings } from './RichTextEditor'; @@ -112,7 +113,8 @@ let config = { sentryOptions: { ...sentryOptions, }, - contentIcons: contentIcons, + contentIcons, + imageScales, loadables, lazyBundles: { cms: [ diff --git a/src/helpers/Image/Image.js b/src/helpers/Image/Image.js new file mode 100644 index 0000000000..b5de6c6c6e --- /dev/null +++ b/src/helpers/Image/Image.js @@ -0,0 +1,126 @@ +import { flattenToAppURL, isInternalURL } from '@plone/volto/helpers'; +import config from '@plone/volto/registry'; + +const getImageType = (image) => { + let imageType = 'external'; + if (image['content-type'] === 'image/svg+xml') { + imageType = 'svg'; + } else if ( + Object.prototype.toString.call(image) === '[object Object]' && + image.scales && + Object.keys(image.scales).length > 0 + ) { + imageType = 'imageObject'; + } else if (typeof image === 'string' && isInternalURL(image)) { + imageType = 'internalUrl'; + } + return imageType; +}; + +/** + * Get src-set list from image + * @param {object | string} image - Image content object or url + * @param {object} options + * @param {number} options.maxSize - maximum size to render + * @param {boolean} options.useOriginal - whether to render original img + * @returns {object} image attributes + * @returns {string} image.src attributes.src + * @returns {string} image.srcset attributes.srcset + */ + +const DEFAULT_MAX_SIZE = 10000; +export const getImageAttributes = ( + image, + { + imageField = 'image', + maxSize = DEFAULT_MAX_SIZE, + useOriginal = false, + minSize = 0, + } = {}, +) => { + const imageScales = config.settings.imageScales; + + const minScale = Object.keys(imageScales).reduce((minScale, scale) => { + if (!minScale || imageScales[scale] < imageScales[minScale]) { + if (minSize > 0 && minSize > imageScales[scale]) { + return minScale; + } + return scale; + } + return minScale; + }, null); + + let attrs = {}; + let imageType = getImageType(image); + + switch (imageType) { + case 'svg': + attrs.src = flattenToAppURL(image.download); + break; + + // Scales object from Plone restapi + // ideal use of Plone images + case 'imageObject': + let sortedScales = Object.values(image.scales) + .filter((scale) => scale.width <= maxSize) + .filter( + (scale, index, array) => + index === + array.findIndex((foundItem) => foundItem.width === scale.width), + ) // avoid duplicates if image is small and original is smaller than scale + .sort((a, b) => { + if (a.width > b.width) return 1; + else if (a.width < b.width) return -1; + else return 0; + }); + + const scale = sortedScales[0]; + attrs.src = flattenToAppURL(scale?.download ?? image.download); + attrs.aspectRatio = Math.round((image.width / image.height) * 100) / 100; + + if (maxSize !== DEFAULT_MAX_SIZE) { + const maxScale = sortedScales[sortedScales.length - 1]; + attrs.width = maxScale.width; + attrs.height = maxScale.height; + } + + attrs.srcSet = sortedScales.map( + (scale) => `${flattenToAppURL(scale.download)} ${scale.width}w`, + ); + + if (useOriginal || sortedScales?.length === 0) + attrs.srcSet = attrs.srcSet.concat( + `${flattenToAppURL(image.download)} ${image.width}w`, + ); + break; + + // Internal URL + case 'internalUrl': + let baseUrl = `${flattenToAppURL(image.split('/@@images')[0])}${ + image.endsWith('/') ? '' : '/' + }@@images/${imageField}`; + attrs.src = `${baseUrl}/${minScale}`; + + attrs.srcSet = Object.keys(imageScales) + .sort((a, b) => { + if (imageScales[a] > imageScales[b]) return 1; + else if (imageScales[a] < imageScales[b]) return -1; + else return 0; + }) + .reduce((srcSet, scale) => { + if (imageScales[scale] <= maxSize) { + return [...srcSet, `${baseUrl}/${scale} ${imageScales[scale]}w`]; + } else return srcSet; + }, []); + + if (useOriginal) attrs.srcSet = attrs.srcSet.concat(`${baseUrl} 1900w`); // expect that is for desktop screens, I don't have actual size + break; + + // External URL or other element + default: + attrs.src = typeof image === 'string' ? image : null; + break; + } + + return attrs; +}; diff --git a/src/helpers/Image/Image.test.js b/src/helpers/Image/Image.test.js new file mode 100644 index 0000000000..a443f737da --- /dev/null +++ b/src/helpers/Image/Image.test.js @@ -0,0 +1,129 @@ +import { getImageAttributes } from './Image'; +import config from '@plone/volto/registry'; + +const ploneImage = { + download: 'http://localhost:8080/Plone/test-images/@@images/image', + width: 1920, + height: 960, + scales: { + icon: { + download: 'http://localhost:8080/Plone/test-images/@@images/image/icon', + width: 32, + height: 16, + }, + large: { + download: 'http://localhost:8080/Plone/test-images/@@images/image/large', + width: 768, + height: 384, + }, + listing: { + download: + 'http://localhost:8080/Plone/test-images/@@images/image/listing', + width: 16, + height: 8, + }, + mini: { + download: 'http://localhost:8080/Plone/test-images/@@images/image/mini', + width: 200, + height: 100, + }, + preview: { + download: + 'http://localhost:8080/Plone/test-images/@@images/image/preview', + width: 400, + height: 200, + }, + thumb: { + download: 'http://localhost:8080/Plone/test-images/@@images/image/thumb', + width: 128, + height: 64, + }, + tile: { + download: 'http://localhost:8080/Plone/test-images/@@images/image/tile', + width: 64, + height: 32, + }, + }, +}; + +describe('Image', () => { + describe('getSrcSet', () => { + config.settings.imageScales = { + large: 768, + preview: 400, + mini: 200, + thumb: 128, + tile: 64, + icon: 32, + listing: 16, + }; + + it('returns srcset from url', () => { + expect( + getImageAttributes( + 'http://localhost:8080/Plone/photo.png/@@images/image', + ), + ).toEqual({ + src: '/photo.png/@@images/image/listing', + srcSet: [ + '/photo.png/@@images/image/listing 16w', + '/photo.png/@@images/image/icon 32w', + '/photo.png/@@images/image/tile 64w', + '/photo.png/@@images/image/thumb 128w', + '/photo.png/@@images/image/mini 200w', + '/photo.png/@@images/image/preview 400w', + '/photo.png/@@images/image/large 768w', + ], + }); + }); + + it('returns srcset from object', () => { + expect(getImageAttributes(ploneImage)).toEqual({ + src: '/test-images/@@images/image/listing', + srcSet: [ + '/test-images/@@images/image/listing 16w', + '/test-images/@@images/image/icon 32w', + '/test-images/@@images/image/tile 64w', + '/test-images/@@images/image/thumb 128w', + '/test-images/@@images/image/mini 200w', + '/test-images/@@images/image/preview 400w', + '/test-images/@@images/image/large 768w', + ], + aspectRatio: 2, + }); + }); + + it('returns srcset from object with maxSize', () => { + expect(getImageAttributes(ploneImage, { maxSize: 200 })).toEqual({ + src: '/test-images/@@images/image/listing', + srcSet: [ + '/test-images/@@images/image/listing 16w', + '/test-images/@@images/image/icon 32w', + '/test-images/@@images/image/tile 64w', + '/test-images/@@images/image/thumb 128w', + '/test-images/@@images/image/mini 200w', + ], + width: 200, + height: 100, + aspectRatio: 2, + }); + }); + + it('returns srcset from url with original included', () => { + expect(getImageAttributes(ploneImage, { useOriginal: true })).toEqual({ + src: '/test-images/@@images/image/listing', + srcSet: [ + '/test-images/@@images/image/listing 16w', + '/test-images/@@images/image/icon 32w', + '/test-images/@@images/image/tile 64w', + '/test-images/@@images/image/thumb 128w', + '/test-images/@@images/image/mini 200w', + '/test-images/@@images/image/preview 400w', + '/test-images/@@images/image/large 768w', + '/test-images/@@images/image 1920w', + ], + aspectRatio: 2, + }); + }); + }); +}); diff --git a/src/helpers/index.js b/src/helpers/index.js index 9be426d90d..b3992e7e0b 100644 --- a/src/helpers/index.js +++ b/src/helpers/index.js @@ -81,6 +81,8 @@ export { hasApiExpander, replaceItemOfArray, } from '@plone/volto/helpers/Utils/Utils'; + +export { getImageAttributes } from './Image/Image'; export { messages } from './MessageLabels/MessageLabels'; export { withBlockSchemaEnhancer, diff --git a/theme/themes/pastanaga/extras/blocks.less b/theme/themes/pastanaga/extras/blocks.less index 70cd71f212..17eabb889c 100644 --- a/theme/themes/pastanaga/extras/blocks.less +++ b/theme/themes/pastanaga/extras/blocks.less @@ -435,6 +435,19 @@ body.has-toolbar.has-sidebar-collapsed .ui.wrapper > .ui.inner.block.full { max-height: 400px; } + picture { + width: 100%; + max-width: 50%; + + img.hero-image { + max-width: none; + } + + @media only screen and (max-width: (@largestMobileScreen)) { + max-width: 100%; + } + } + .image-add { min-width: 50%; } @@ -447,6 +460,10 @@ body.has-toolbar.has-sidebar-collapsed .ui.wrapper > .ui.inner.block.full { position: relative; z-index: 1; display: flex; + + @media only screen and (max-width: (@largestMobileScreen)) { + flex-wrap: wrap; + } } .title-editor, @@ -783,8 +800,8 @@ body.has-toolbar.has-sidebar-collapsed .ui.wrapper > .ui.inner.block.full { .listing-item { margin-bottom: 20px; - img { - width: 15%; + picture { + max-width: 15%; margin-right: 20px; @media only screen and (max-width: (@largestMobileScreen)) { diff --git a/theme/themes/pastanaga/extras/main.less b/theme/themes/pastanaga/extras/main.less index db00239ba1..cc0f8236a4 100644 --- a/theme/themes/pastanaga/extras/main.less +++ b/theme/themes/pastanaga/extras/main.less @@ -555,6 +555,45 @@ body.has-toolbar-collapsed .mobile-menu { transition: transform 0.5s cubic-bezier(0.09, 0.11, 0.24, 0.91); } +.volto-image { + &.floated { + margin-right: 1em; + margin-bottom: 1em; + float: left; + + &.right { + margin-right: 0; + margin-bottom: 1em; + margin-left: 1em; + float: right; + } + } + + &.large { + width: 50%; + } + + &.medium { + width: 25%; + } + + &.small { + width: 15%; + } + + &.thumb { + width: 128px; + max-height: 128px; + } + + &.responsive { + img { + width: 100%; + height: auto; + } + } +} + // Deprecated as per https://github.com/plone/volto/issues/1265 // @import 'utils'; @import 'toolbar';