Skip to content

Commit 9311217

Browse files
author
Nir Maoz
authored
Add support for lazy loading images, placeholder and accessibility (#162)
1 parent b7601f6 commit 9311217

File tree

15 files changed

+441
-40
lines changed

15 files changed

+441
-40
lines changed

bundlewatch.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const bundlewatchConfig = {
22
files: [
33
{
44
path: './dist/cloudinary-react.js',
5-
maxSize: '42kb'
5+
maxSize: '43kb'
66
}
77
],
88
defaultCompression: 'gzip',
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
describe('Placeholder Image', () => {
2+
beforeEach(() => {
3+
// runs before each test in the block
4+
cy.visit('/');
5+
cy.get('#lazyBtn').click(); // Click on button
6+
});
7+
it('Should not have src attribute when not in view', () => {
8+
cy.get('#lazy')
9+
.should('not.be.visible')
10+
.should('not.have.attr', 'src');
11+
});
12+
it('Should have src attribute when view', () => {
13+
cy.scrollTo(0, 3000);
14+
cy.get('#lazy')
15+
.should('be.visible')
16+
.should('have.attr', 'src').should('equal', 'http://res.cloudinary.com/demo/image/upload/c_scale,w_300/sample');
17+
});
18+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
describe('Placeholder Image', () => {
2+
beforeEach(() => {
3+
// runs before each test in the block
4+
cy.visit('/');
5+
cy.get('#placeholderBtn').click();
6+
});
7+
8+
/**
9+
* Cypress seems to be not fast enough to catch the placeholder render.
10+
* So we test that the placeholder is rendered in out Unit Tests,
11+
* And here we make sure that eventually we render the original image.
12+
*/
13+
it('Show original image', () => {
14+
cy.get('#placeholder')
15+
.should('be.visible')
16+
.should('have.attr', 'src').should('equal', 'http://res.cloudinary.com/demo/image/upload/c_scale,w_300/sample')
17+
});
18+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
describe('Placeholder Lazy Load Image', () => {
2+
beforeEach(() => {
3+
// runs before each test in the block
4+
cy.visit('/');
5+
cy.get('#lazyPlaceholderBtn').click(); // Click on button
6+
});
7+
it('Should not have src attribute when not in view', () => {
8+
cy.get('#lazyPlaceholder-cld-placeholder')
9+
.should('not.be.visible')
10+
.should('not.have.attr', 'src');
11+
cy.get('#lazyPlaceholder-cld-placeholder')
12+
.should('have.attr', 'data-src').should('equal', "http://res.cloudinary.com/demo/image/upload/c_scale,w_300/e_blur:2000,f_auto,q_1/sample");
13+
});
14+
it('Should have src attribute when view', () => {
15+
cy.scrollTo(0, 3000);
16+
cy.get('#lazyPlaceholder')
17+
.should('be.visible')
18+
.should('have.attr', 'src').should('equal', 'http://res.cloudinary.com/demo/image/upload/c_scale,w_300/sample');
19+
});
20+
});
Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,25 @@
11
describe('Responsive Image', () => {
2-
it('Responsive Image', () => {
2+
beforeEach(() => {
3+
// runs before each test in the block
34
cy.visit('/');
4-
5+
cy.get('#responsiveBtn').click(); // Click on button
6+
});
7+
it('Responsive Image', () => {
58
cy.get('#responsive')
69
.should('have.attr', 'data-src').should('equal','http://res.cloudinary.com/demo/image/upload/c_scale,w_auto/sample')
710
cy.get('#responsive')
811
.should('have.attr', 'src').should('equal','http://res.cloudinary.com/demo/image/upload/c_scale,w_400/sample')
912
});
1013
it('Disabled Breakpoints', () => {
11-
cy.visit('/');
12-
1314
cy.get('#responsive')
1415
.should('have.attr', 'data-src').should('equal','http://res.cloudinary.com/demo/image/upload/c_scale,w_auto/sample')
1516
cy.get('#disable-breakpoints')
1617
.should('have.attr', 'src').should('equal','http://res.cloudinary.com/demo/image/upload/c_scale,w_330/sample')
1718
});
1819
it('Enabled Breakpoints', () => {
19-
cy.visit('/');
20-
2120
cy.get('#responsive')
2221
.should('have.attr', 'data-src').should('equal','http://res.cloudinary.com/demo/image/upload/c_scale,w_auto/sample')
2322
cy.get('#breakpoints')
2423
.should('have.attr', 'src').should('equal','http://res.cloudinary.com/demo/image/upload/c_scale,w_450/sample')
2524
});
26-
27-
28-
2925
});

e2e-test/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"start": "react-scripts start",
1616
"build": "react-scripts build",
1717
"cypress": "./node_modules/.bin/cypress run",
18-
"pretest": "npm install",
18+
"pretest": "npm uninstall cloudinary-react && npm install cloudinary-react.tgz && npm install",
1919
"test": "start-server-and-test start http://localhost:3000 cypress",
2020
"eject": "react-scripts eject"
2121
},

e2e-test/src/App.js

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,73 @@
1-
import React from 'react';
2-
import {Image} from 'cloudinary-react';
1+
import React, {Fragment, useState} from 'react';
2+
import {Image, Placeholder} from 'cloudinary-react';
33
import './App.css';
44

5+
const tests = [
6+
'responsive',
7+
'placeholder',
8+
'lazy',
9+
'lazyPlaceholder'
10+
];
11+
512
function App() {
13+
const [test, setTest] = useState(0);
14+
15+
const Buttons = () => (
16+
<Fragment>
17+
{
18+
tests.map((t, i) =>
19+
<button key={"btn-"+i} id={t + 'Btn'} onClick={() => setTest(t)}>{t + ' test'}</button>
20+
)
21+
}
22+
</Fragment>
23+
);
24+
625
return (
7-
<div>
8-
<h1>Responsive Image</h1>
9-
<div style={{width: "330px"}}>
10-
<Image id="responsive" publicId="sample" cloudName="demo" width="auto" crop="scale" responsive/>
26+
<Fragment>
27+
<Buttons />
28+
{test === 'responsive' &&
29+
<Fragment>
30+
<h1>Responsive Image</h1>
31+
<div style={{width: "330px"}}>
32+
<Image id="responsive" publicId="sample" cloudName="demo" width="auto" crop="scale" responsive/>
33+
</div>
34+
<div style={{width: "330px"}}>
35+
<Image id="disable-breakpoints" publicId="sample" cloudName="demo" width="auto" crop="scale" responsive
36+
responsiveUseBreakpoints={false}/>
37+
</div>
38+
<div style={{width: "330px"}}>
39+
<Image id="breakpoints" publicId="sample" cloudName="demo" width="auto" crop="scale" responsive
40+
responsiveUseBreakpoints={true} breakpoints={() => 450}/>
41+
</div>
42+
</Fragment>
43+
}
44+
{test === 'placeholder' &&
45+
<div>
46+
<h1>Placeholder</h1>
47+
<Image id="placeholder" publicId="sample" cloudName="demo" width="300" crop="scale">
48+
<Placeholder/>
49+
</Image>
1150
</div>
12-
<div style={{width: "330px"}}>
13-
<Image id="disable-breakpoints" publicId="sample" cloudName="demo" width="auto" crop="scale" responsive responsiveUseBreakpoints={false}/>
51+
}
52+
{test === 'lazy' &&
53+
<div>
54+
<h1>Lazy</h1>
55+
<div style={{marginTop: '3000px'}}>
56+
<Image id="lazy" publicId="sample" cloudName="demo" width="300" crop="scale" loading="lazy"/>
57+
</div>
1458
</div>
15-
<div style={{width: "330px"}}>
16-
<Image id="breakpoints" publicId="sample" cloudName="demo" width="auto" crop="scale" responsive responsiveUseBreakpoints={true} breakpoints={()=>450}/>
59+
}
60+
{test === 'lazyPlaceholder' &&
61+
<div>
62+
<h1>Lazy Placeholder</h1>
63+
<div style={{marginTop: '3000px'}}>
64+
<Image id="lazyPlaceholder" publicId="sample" cloudName="demo" width="300" crop="scale" loading="lazy">
65+
<Placeholder/>
66+
</Image>
67+
</div>
1768
</div>
18-
</div>
69+
}
70+
</Fragment>
1971
);
2072
}
2173

src/Util/cloudinaryCoreUtils.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,19 @@ const getVideoTag = (props) => getTag(props, "video");
3939
* Cloudinary underlying JS library will handle responsive behavior
4040
* @param {HTMLImageElement} img
4141
* @param {object} options
42+
* @Return callback that when called, will remove the listener created by Cloudinary.responsive
4243
*/
4344
const makeElementResponsive = (img, options) =>{
4445
const snakeCaseOptions = Util.withSnakeCaseKeys(options);
4546
const cld = getConfiguredCloudinary(snakeCaseOptions); // Initialize cloudinary with new props
4647
cld.cloudinary_update(img, snakeCaseOptions);
47-
cld.responsive(snakeCaseOptions, false);
48+
return cld.responsive(snakeCaseOptions, false);
4849
};
4950

5051
export {
5152
nonEmpty,
5253
getImageTag,
5354
getVideoTag,
54-
makeElementResponsive
55+
makeElementResponsive,
56+
getConfiguredCloudinary
5557
};

src/components/CloudinaryComponent/CloudinaryComponent.js

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ class CloudinaryComponent extends PureComponent {
5353
return this.context || {};
5454
}
5555

56+
/**
57+
* React function: Called when this element is in view
58+
*/
59+
onIntersect = () =>{
60+
this.setState({isInView: true})
61+
}
62+
63+
getChildPlaceholder(children){
64+
if (children) {
65+
return React.Children.toArray(children)
66+
.find(child => isCloudinaryComponent(child, "CloudinaryPlaceholder"));
67+
}
68+
}
69+
5670
getChildTransformations(children) {
5771
let result = children ? React.Children.toArray(children)
5872
.filter(child => isCloudinaryComponent(child, "CloudinaryTransformation"))
@@ -76,13 +90,19 @@ class CloudinaryComponent extends PureComponent {
7690
* @protected
7791
*/
7892
getTransformation(extendedProps) {
79-
let {children, ...rest} = extendedProps;
93+
let {children, accessibility, placeholder, ...rest} = extendedProps;
8094
let ownTransformation = only(Util.withCamelCaseKeys(rest), Transformation.methods) || {};
8195
let childrenOptions = this.getChildTransformations(children);
8296
if (!Util.isEmpty(childrenOptions)) {
8397
ownTransformation.transformation = childrenOptions;
8498
}
8599

100+
//Append placeholder and accessibility if exists
101+
const advancedTransformations = {accessibility, placeholder};
102+
Object.keys(advancedTransformations).filter(k=>advancedTransformations[k]).map(k=>{
103+
ownTransformation[k] = advancedTransformations[k];
104+
});
105+
86106
return ownTransformation;
87107
}
88108

@@ -106,14 +126,23 @@ class CloudinaryComponent extends PureComponent {
106126
, {});
107127
}
108128

129+
/**
130+
* Generated a configured Cloudinary object.
131+
* @param extendedProps React props combined with custom Cloudinary configuration options
132+
* @return {Cloudinary} configured using extendedProps
133+
*/
134+
getConfiguredCloudinary(extendedProps){
135+
const options = Util.extractUrlParams(Util.withSnakeCaseKeys(extendedProps));
136+
return Cloudinary.new(options);
137+
}
138+
109139
/**
110140
* Generate a Cloudinary resource URL based on the options provided and child Transformation elements
111141
* @param extendedProps React props combined with custom Cloudinary configuration options
112142
* @returns {string} a cloudinary URL
113143
* @protected
114144
*/
115145
getUrl(extendedProps) {
116-
117146
const {publicId} = extendedProps;
118147
const cl = getConfiguredCloudinary(extendedProps);
119148
return cl.url(publicId, this.getTransformation(extendedProps));

0 commit comments

Comments
 (0)