Skip to content

Commit

Permalink
Apply LQIP styles with dynamically generated CSS, to support FastBoot
Browse files Browse the repository at this point in the history
Instead of applying styles with JS *after* rehydration, which is too late.
  • Loading branch information
simonihmig committed Feb 22, 2021
1 parent 8f930a6 commit f007eb3
Show file tree
Hide file tree
Showing 16 changed files with 192 additions and 89 deletions.
4 changes: 1 addition & 3 deletions addon/components/responsive-image.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,12 @@
src={{this.src}}
width={{this.width}}
height={{this.height}}
class="eri-{{this.layout}}"
class={{this.classNames}}
loading="lazy"
decoding="async"
...attributes
{{style
(if this.showLqipImage (hash background-image=this.lqipImage background-size="cover"))
(if this.showLqipBlurhash (hash background-image=this.lqipBlurhash background-size="cover"))
(if this.showLqipColor (hash background-color=this.lqipColor))
}}
{{on "load" this.onLoad}}
/>
Expand Down
57 changes: 10 additions & 47 deletions addon/components/responsive-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,12 @@ import ResponsiveImageService, {
ImageMeta,
ImageType,
LqipBlurhash,
LqipColor,
LqipInline,
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';
import {
macroCondition,
getOwnConfig /*, importSync*/,
} from '@embroider/macros';
import { getOwnConfig, macroCondition } from '@embroider/macros';
import { decode } from 'blurhash';

declare module '@embroider/macros' {
Expand Down Expand Up @@ -187,47 +180,17 @@ export default class ResponsiveImageComponent extends Component<ResponsiveImageC
}
}

get hasLqipImage(): boolean {
return this.meta.lqip?.type === 'inline';
}

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

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

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

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

get hasLqipColor(): boolean {
return this.meta.lqip?.type === 'color';
}

get showLqipColor(): boolean {
return !this.isLoaded && this.hasLqipColor;
}

get lqipColor(): string | undefined {
if (!this.hasLqipColor) {
return undefined;
get classNames(): string {
const classNames = [`eri-${this.layout}`];
const lqip = this.meta.lqip;
if (lqip && !this.isLoaded) {
classNames.push(`eri-lqip-${lqip.type}`);
if (lqip.type === 'color' || lqip.type === 'inline') {
classNames.push(lqip.class);
}
}
const lqip = (this.meta as Required<Meta>).lqip as LqipColor;

return lqip.color;
return classNames.join(' ');
}

get hasLqipBlurhash(): boolean {
Expand Down
6 changes: 2 additions & 4 deletions addon/services/responsive-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,12 @@ export interface LqipBase {

export interface LqipInline extends LqipBase {
type: 'inline';
image: string;
width: number;
height: number;
class: string;
}

export interface LqipColor extends LqipBase {
type: 'color';
color: string;
class: string;
}

export interface LqipBlurhash extends LqipBase {
Expand Down
4 changes: 4 additions & 0 deletions addon/styles/ember-responsive-image.css
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@
.eri-responsive {
content-visibility: auto;
}

.eri-lqip-inline {
background-size: cover;
}
7 changes: 0 additions & 7 deletions addon/utils/data-uri.ts

This file was deleted.

16 changes: 16 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
const path = require('path');
const Funnel = require('broccoli-funnel');
const Writer = require('./lib/image-writer');
const CssWriter = require('./lib/css-writer');
const fs = require('fs-extra');
const map = require('broccoli-stew').map;
const mergeTrees = require('broccoli-merge-trees');
Expand All @@ -26,6 +27,7 @@ module.exports = {
metaData: {},
configData: {},
app: null,
cssExtensions: [],
metadataExtensions: [],
extendedMetaData: null,
imagePreProcessors: [],
Expand Down Expand Up @@ -62,6 +64,10 @@ module.exports = {
this.metadataExtensions.push({ callback, target });
},

addCssExtension(callback, target) {
this.cssExtensions.push({ callback, target });
},

/**
* Add a callback function to hook into image processing before the addon's image processes are executed.
* The callback method you provide must have the following signature:
Expand Down Expand Up @@ -223,6 +229,7 @@ module.exports = {
// we write our image meta data as a script tag into the app's index.html, which the service will read from
// (that happens only in the browser, where we have easy access to the DOM. For FastBoot this is different, see below)
if (type === 'head-footer') {
console.log('content');
return [
'<script id="ember_responsive_image_meta" type="application/json">',
JSON.stringify(this.extendMetadata()),
Expand All @@ -231,6 +238,15 @@ module.exports = {
}
},

treeForAddonStyles(tree) {
const dynTree = new CssWriter(
[this.processingTree],
() => this.metaData,
this.cssExtensions
);
return mergeTrees([tree, dynTree]);
},

treeForFastBoot() {
// we have to rename our own fastboot tree so that our dummy app works correctly, due to this bug in ember-cli-fastboot:
// https://github.com/ember-fastboot/ember-cli-fastboot/issues/807
Expand Down
51 changes: 51 additions & 0 deletions lib/css-writer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
'use strict';

const CachingWriter = require('broccoli-caching-writer');
const path = require('path');
const fs = require('fs-extra');
const baseN = require('base-n');

const b64 = baseN.create();

class CssWriter extends CachingWriter {
constructor(inputNodes, getMeta, cssExtensions, options) {
options = options || {};
options.cacheInclude = [/.*/];
super(inputNodes, options);

this.getMeta = getMeta;
this.cssExtensions = cssExtensions;
}

async build() {
const destinationPath = path.join(
this.outputPath,
'ember-responsive-image-dynamic.css'
);

await fs.writeFile(destinationPath, this.generateCss());
}

generateCss() {
const meta = this.getMeta();
let cssEntries = [];

let classCounter = 0;
const generateClassName = () => `eri-dyn-${b64.encode(classCounter++)}`;

for (const [image, imageMeta] of Object.entries(meta)) {
for (const { callback, target } of this.cssExtensions) {
const css = callback.call(target, image, imageMeta, {
generateClassName,
});
if (css) {
cssEntries.push(css);
}
}
}

return cssEntries.join(`\n`);
}
}

module.exports = CssWriter;
16 changes: 14 additions & 2 deletions lib/plugins/lqip-color.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class LqipColorPlugin {

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

canProcessImage(config) {
Expand All @@ -24,19 +25,30 @@ class LqipColorPlugin {
dominant.g.toString(16) +
dominant.b.toString(16);

this.metaData.set(image, { type: 'color', color });
this.metaData.set(image, { color });

return sharped;
}

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

return metadata;
}

addCss(image, metaData, { generateClassName }) {
const ourMeta = this.metaData.get(image);
if (ourMeta) {
const className = generateClassName();
ourMeta.class = className;
return `.${className} { background-color: ${ourMeta.color}; }`;
}

return undefined;
}
}

module.exports = LqipColorPlugin;
24 changes: 23 additions & 1 deletion lib/plugins/lqip-inline.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const sharp = require('sharp');
const dataUri = require('../utils/data-uri');
const blurrySvg = require('../utils/blurry-svg');

class LqipInlinePlugin {
constructor(addon) {
Expand All @@ -7,6 +9,7 @@ class LqipInlinePlugin {

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

canProcessImage(config) {
Expand Down Expand Up @@ -57,11 +60,30 @@ class LqipInlinePlugin {
addMetaData(image, metadata /*, config*/) {
const ourMeta = this.metaData.get(image);
if (ourMeta) {
metadata.lqip = ourMeta;
metadata.lqip = { type: 'inline', class: ourMeta.class };
}

return metadata;
}

addCss(image, metaData, { generateClassName }) {
const ourMeta = this.metaData.get(image);
if (ourMeta) {
const className = generateClassName();
ourMeta.class = className;
const uri = dataUri(
blurrySvg(
dataUri(ourMeta.image, 'image/png', true),
ourMeta.width,
ourMeta.height
),
'image/svg+xml'
);
return `.${className} { background-image: url(${uri}); }`;
}

return undefined;
}
}

module.exports = LqipInlinePlugin;
8 changes: 2 additions & 6 deletions addon/utils/blurry-svg.ts → lib/utils/blurry-svg.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
// 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 {
module.exports = function blurrySvg(src, width, height) {
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>`;
}
};
5 changes: 5 additions & 0 deletions lib/utils/data-uri.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = function dataUri(data, type, base64 = false) {
return `data:${type};base64,${
base64 ? data : Buffer.from(data).toString('base64')
}`;
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"@glimmer/component": "^1.0.3",
"@glimmer/tracking": "^1.0.3",
"async-q": "^0.3.1",
"base-n": "^3.0.0",
"blurhash": "^1.1.3",
"broccoli-caching-writer": "^3.0.3",
"broccoli-funnel": "^3.0.3",
Expand Down
6 changes: 5 additions & 1 deletion tests/dummy/app/templates/image.hbs
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
<ResponsiveImage @src="assets/images/tests/test.png"/>
<ResponsiveImage @src="assets/images/tests/test.png" data-test-simple-image/>

<ResponsiveImage @src="assets/images/tests/lqip/color.jpg" data-test-lqip-image="color"/>
<ResponsiveImage @src="assets/images/tests/lqip/inline.jpg" data-test-lqip-image="inline"/>
<ResponsiveImage @src="assets/images/tests/lqip/blurhash.jpg" data-test-lqip-image="blurhash"/>
32 changes: 30 additions & 2 deletions tests/fastboot/image-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,35 @@ module('FastBoot | image', function (hooks) {
test('it renders an image', async function (assert) {
await visit('/image');

assert.dom('img').exists();
assert.dom('img').hasAttribute('src', '/assets/images/tests/test640w.png');
assert.dom('img[data-test-simple-image]').exists();
assert
.dom('img[data-test-simple-image]')
.hasAttribute('src', '/assets/images/tests/test640w.png');
});

test('it renders lqip color', async function (assert) {
await visit('/image');

assert.dom('img[data-test-lqip-image=color]').exists();
assert
.dom('img[data-test-lqip-image=color]')
.hasStyle({ 'background-color': 'rgb(88, 72, 56)' });
});

test('it renders lqip inline', async function (assert) {
await visit('/image');

assert.dom('img[data-test-lqip-image=inline]').exists();
assert
.dom('img[data-test-lqip-image=inline]')
.hasStyle({ 'background-size': 'cover' });
assert.ok(
window
.getComputedStyle(
document.querySelector('img[data-test-lqip-image=inline]')
)
.backgroundImage?.match(/data:image\/svg/),
'it has a background SVG'
);
});
});
Loading

0 comments on commit f007eb3

Please sign in to comment.