Skip to content

Commit

Permalink
Merge pull request #133 from kaliber5/lqip
Browse files Browse the repository at this point in the history
Add built-in support for a blurry placeholder (LQIP)
  • Loading branch information
simonihmig authored Feb 5, 2021
2 parents 1e39f08 + ccc74bb commit f00e255
Show file tree
Hide file tree
Showing 14 changed files with 245 additions and 14 deletions.
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 @@ -14,5 +14,7 @@
loading="lazy"
decoding="async"
...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

0 comments on commit f00e255

Please sign in to comment.