Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add built-in support for a blurry placeholder (LQIP) #133

Merged
merged 4 commits into from
Feb 5, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ module.exports = {
'blueprints/*/index.js',
'config/**/*.js',
'tests/dummy/config/**/*.js',
'lib/*.js',
'lib/**/*.js',
],
excludedFiles: [
'addon/**',
Expand Down
2 changes: 2 additions & 0 deletions addon/components/responsive-image.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@
class="eri-{{this.layout}}"
loading="lazy"
...attributes
{{style (if this.showLqipImage (hash background-image=this.lqipImage background-size="cover"))}}
{{on "load" this.onLoad}}
/>
</picture>
43 changes: 43 additions & 0 deletions addon/components/responsive-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ import { inject as service } from '@ember/service';
import ResponsiveImageService, {
ImageMeta,
ImageType,
Meta,
} from 'ember-responsive-image/services/responsive-image';
import { assert } from '@ember/debug';
import dataUri from 'ember-responsive-image/utils/data-uri';
import blurrySvg from 'ember-responsive-image/utils/blurry-svg';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

interface ResponsiveImageComponentArgs {
image: string;
Expand Down Expand Up @@ -40,6 +45,9 @@ export default class ResponsiveImageComponent extends Component<ResponsiveImageC
@service
responsiveImage!: ResponsiveImageService;

@tracked
isLoaded = false;

constructor(owner: unknown, args: ResponsiveImageComponentArgs) {
super(owner, args);
assert('No image argument supplied for <ResponsiveImage>', args.image);
Expand Down Expand Up @@ -117,6 +125,10 @@ export default class ResponsiveImageComponent extends Component<ResponsiveImageC
}
}

get meta(): Meta {
return this.responsiveImage.getMeta(this.args.image);
}

/**
* the image source which fits at best for the size and screen
*/
Expand Down Expand Up @@ -157,4 +169,35 @@ export default class ResponsiveImageComponent extends Component<ResponsiveImageC
return undefined;
}
}

get hasLqipImage(): boolean {
return !!this.meta.lqip?.image;
}

get showLqipImage(): boolean {
return !this.isLoaded && this.hasLqipImage;
}

get lqipImage(): string | undefined {
if (!this.hasLqipImage) {
return undefined;
}
const { lqip } = this.meta as Required<Meta>;

const uri = dataUri(
blurrySvg(
dataUri(lqip.image, 'image/png', true),
lqip.width,
lqip.height
),
'image/svg+xml'
);

return `url("${uri}")`;
}

@action
onLoad(): void {
this.isLoaded = true;
}
}
24 changes: 15 additions & 9 deletions addon/services/responsive-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export interface ImageMeta {

export interface Meta {
images: ImageMeta[];
lqip?: {
image: string;
width: number;
height: number;
};
}

/**
Expand All @@ -39,22 +44,23 @@ export default class ResponsiveImageService extends Service {
* return the images with the different widths
*/
getImages(imageName: string, type?: ImageType): ImageMeta[] {
assert(
`There is no data for image ${imageName}: ${this.meta}`,
Object.prototype.hasOwnProperty.call(this.meta, imageName)
);
assert(
`There is no image data for image ${imageName}`,
Object.prototype.hasOwnProperty.call(this.meta[imageName], 'images')
);
let images = this.meta[imageName].images;
let images = this.getMeta(imageName).images;
if (type) {
images = images.filter((image) => image.type === type);
}

return images;
}

getMeta(imageName: string): Meta {
assert(
`There is no data for image ${imageName}: ${this.meta}`,
Object.prototype.hasOwnProperty.call(this.meta, imageName)
);

return this.meta[imageName];
}

private getType(imageName: string): ImageType {
const extension = imageName.split('.').pop();
assert(`No extension found for ${imageName}`, extension);
Expand Down
12 changes: 12 additions & 0 deletions addon/utils/blurry-svg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// taken from https://github.com/google/eleventy-high-performance-blog/blob/5ed39db7fd3f21ae82ac1a8e833bf283355bd3d0/_11ty/blurry-placeholder.js#L51

export default function blurrySvg(
src: string,
width: number,
height: number
): string {
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 ${width} ${height}">
<filter id="b" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation=".5"></feGaussianBlur><feComponentTransfer><feFuncA type="discrete" tableValues="1 1"></feFuncA></feComponentTransfer></filter>
<image filter="url(#b)" preserveAspectRatio="none" height="100%" width="100%" xlink:href="${src}"></image>
</svg>`;
}
7 changes: 7 additions & 0 deletions addon/utils/data-uri.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export default function dataUri(
data: string,
type: string,
base64 = false
): string {
return `data:${type};base64,${base64 ? data : btoa(data)}`;
}
9 changes: 9 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ module.exports = {
extendedMetaData: null,
imagePreProcessors: [],
imagePostProcessors: [],
plugins: [],

/**
* Add a callback function to change the generated metaData per origin image.
Expand Down Expand Up @@ -153,6 +154,7 @@ module.exports = {
this._super.included.apply(this, arguments);
this.app = parentAddon || app;
this.processingTree = this.createProcessingTree();
this.initPlugins();
},

config(env, baseConfig) {
Expand Down Expand Up @@ -182,6 +184,13 @@ module.exports = {
});
},

initPlugins() {
walk('lib/plugins', { globs: ['*.js'] }).forEach((file) => {
const Plugin = require(`./lib/plugins/${file}`);
this.plugins.push(new Plugin(this));
});
},

validateConfigItem(config) {
if (!config.include) {
throw new SilentError(
Expand Down
66 changes: 66 additions & 0 deletions lib/plugins/lqip-inline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const sharp = require('sharp');

class LqipInlinePlugin {
constructor(addon) {
this.processed = [];
this.metaData = new Map();

addon.addMetadataExtension(this.addMetaData, this);
addon.addImagePreProcessor(this.imagePreProcessor, this);
}

canProcessImage(config) {
return config.lqip && config.lqip.type === 'inline';
}

async getLqipDimensions(config, sharped) {
const meta = await sharped.metadata();
const targetPixels = config.lqip.targetPixels || 60;
const aspectRatio = meta.width / meta.height;

// taken from https://github.com/google/eleventy-high-performance-blog/blob/5ed39db7fd3f21ae82ac1a8e833bf283355bd3d0/_11ty/blurry-placeholder.js#L74-L92
let bitmapHeight = targetPixels / aspectRatio;
bitmapHeight = Math.sqrt(bitmapHeight);
const bitmapWidth = targetPixels / bitmapHeight;
return { width: Math.round(bitmapWidth), height: Math.round(bitmapHeight) };
}

async imagePreProcessor(sharped, image, _width, config) {
if (this.processed.includes(image) || !this.canProcessImage(config)) {
return sharped;
}
this.processed.push(image);

const { width, height } = await this.getLqipDimensions(config, sharped);
const buffer = await sharped.toBuffer();
const lqi = await sharp(buffer)
.resize(width, height, {
withoutEnlargement: true,
fit: 'fill',
})
.png();

const sharpMeta = await lqi.metadata();

const meta = {
image: (await lqi.toBuffer()).toString('base64'),
width: sharpMeta.width,
height: sharpMeta.height,
};

this.metaData.set(image, meta);

return sharped;
}

addMetaData(image, metadata /*, config*/) {
const ourMeta = this.metaData.get(image);
if (ourMeta) {
metadata.lqip = ourMeta;
}

return metadata;
}
}

module.exports = LqipInlinePlugin;
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"ember-cli-babel": "^7.23.1",
"ember-cli-htmlbars": "^5.3.1",
"ember-cli-typescript": "^4.1.0",
"ember-style-modifier": "^0.6.0",
"fs-extra": "^9.1.0",
"minimatch": "^3.0.4",
"sharp": "^0.27.1",
Expand Down Expand Up @@ -146,5 +147,8 @@
"release": true,
"tokenRef": "GITHUB_AUTH"
}
},
"volta": {
"node": "10.23.2"
}
}
5 changes: 5 additions & 0 deletions tests/dummy/app/templates/index.hbs
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
<h2>LQIP</h2>

<ResponsiveImage @image="assets/images/lqip/inline.jpg"/>


<h2>Fixed</h2>

<ResponsiveImage @width={{200}} @image="assets/images/test.png"/>
Expand Down
15 changes: 14 additions & 1 deletion tests/dummy/config/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@ module.exports = function (environment) {
'responsive-image': [
{
include: 'assets/images/**/*',
exclude: 'assets/images/small.png',
exclude: ['assets/images/small.png', 'assets/images/lqip/**/*'],
quality: 50,
supportedWidths: [50, 100, 640],
lqip: {
type: 'color',
},
removeSource: true,
justCopy: false,
},
Expand All @@ -36,6 +39,16 @@ module.exports = function (environment) {
removeSource: false,
supportedWidths: [10, 25],
},
{
include: 'assets/images/lqip/*.jpg',
quality: 50,
supportedWidths: [100, 640],
lqip: {
type: 'inline',
},
removeSource: true,
justCopy: false,
},
],
};

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 35 additions & 0 deletions tests/integration/components/responsive-image-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,41 @@ module('Integration: Responsive Image Component', function (hooks) {
});
});

module('LQIP', function () {
module('inline', function () {
test('it sets LQIP SVG as background', async function (assert) {
let resolve;
const waitUntilLoaded = new Promise((r) => {
resolve = r;
});
this.onload = () => setTimeout(resolve, 0);

await render(
hbs`<ResponsiveImage @image="assets/images/lqip/inline.jpg" {{on "load" this.onload}}/>`
);

assert.ok(
this.element
.querySelector('img')
.style.backgroundImage?.match(/data:image\/svg/),
'it has a background SVG'
);
assert.dom('img').hasStyle({ 'background-size': 'cover' });
assert.ok(
this.element.querySelector('img').style.backgroundImage?.length > 100,
'the background SVG has a reasonable length'
);

await waitUntilLoaded;

assert.notOk(
this.element.querySelector('img').style.backgroundImage,
'after image is loaded the background SVG is removed'
);
});
});
});

test('it renders a source for every format', async function (assert) {
await render(hbs`<ResponsiveImage @image="assets/images/test.png"/>`);

Expand Down
Loading