Skip to content

Commit

Permalink
Support fixed and responsive layouts
Browse files Browse the repository at this point in the history
This accumulates a few (breaking) changes:
* will add the CSS for responsive (fluid) image layout (the default)
* when passing `@width` and/or `@height` arguments, it will opt into fixed layout (closes #117)
* in fixed layout, it will pick the (fallback) `src` attribute based on the fixed width, pick `srcset` sources for that given width and pixel densities of `1x` and `2x`, and `width` and `height` with the appropriate fixed values (if one is missing, it will be computed based on the image's aspect ratio)
* in responsive layout, `width` and `height` attributes are rendered even though the *actual* size is responsive, to let the browser know the correct vertical size (even before the image has been loaded), to reduce [Cumulative Layout Shift](https://web.dev/cls/) (Closes #88)
* some (breaking) related changes in the service's API, removing `getImageBySize()`, renaming `getImageDataBySize()` to `getImageMetaBySize()` and introducing `getImageMetaByWidth()`
  • Loading branch information
simonihmig committed Feb 5, 2021
1 parent 89aa535 commit a889521
Show file tree
Hide file tree
Showing 9 changed files with 476 additions and 306 deletions.
3 changes: 3 additions & 0 deletions addon/components/responsive-image.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
{{/each}}
<img
src={{this.src}}
width={{this.width}}
height={{this.height}}
class="eri-{{this.layout}}"
loading="lazy"
...attributes
/>
Expand Down
133 changes: 114 additions & 19 deletions addon/components/responsive-image.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import ResponsiveImageService from 'ember-responsive-image/services/responsive-image';
import ResponsiveImageService, {
ImageMeta,
} from 'ember-responsive-image/services/responsive-image';
import { assert } from '@ember/debug';

interface ResponsiveImageComponentArgs {
image: string;
size?: number;
sizes?: string;
width?: number;
height?: number;
}

interface PictureSource {
Expand All @@ -14,34 +19,124 @@ interface PictureSource {
sizes?: string;
}

enum Layout {
RESPONSIVE = 'responsive',
FIXED = 'fixed',
}

const PIXEL_DENSITIES = [1, 2];

export default class ResponsiveImageComponent extends Component<ResponsiveImageComponentArgs> {
@service
responsiveImage!: ResponsiveImageService;

constructor(owner: unknown, args: ResponsiveImageComponentArgs) {
super(owner, args);
assert('No image argument supplied for <ResponsiveImage>', args.image);
}

get layout(): Layout {
return this.args.width === undefined && this.args.height === undefined
? Layout.RESPONSIVE
: Layout.FIXED;
}

get sources(): PictureSource[] {
return this.responsiveImage
.getAvailableTypes(this.args.image)
.map((type) => {
const sources = this.responsiveImage
.getImages(this.args.image, type)
.map((imageMeta) => `${imageMeta.image} ${imageMeta.width}w`);

return {
srcset: sources.join(', '),
sizes:
this.args.sizes ??
(this.args.size ? `${this.args.size}vw` : undefined),
type: `image/${type}`,
};
});
if (this.layout === Layout.RESPONSIVE) {
return this.responsiveImage
.getAvailableTypes(this.args.image)
.map((type) => {
const sources: string[] = this.responsiveImage
.getImages(this.args.image, type)
.map((imageMeta) => `${imageMeta.image} ${imageMeta.width}w`);

return {
srcset: sources.join(', '),
sizes:
this.args.sizes ??
(this.args.size ? `${this.args.size}vw` : undefined),
type: `image/${type}`,
};
});
} else {
const width = this.width;
if (width === undefined) {
return [];
}

return this.responsiveImage
.getAvailableTypes(this.args.image)
.map((type) => {
const sources: string[] = PIXEL_DENSITIES.map((density) => {
const imageMeta = this.responsiveImage.getImageMetaByWidth(
this.args.image,
width * density,
type
)!;

return `${imageMeta.image} ${density}x`;
}).filter((source) => source !== undefined);

return {
srcset: sources.join(', '),
type: `image/${type}`,
};
});
}
}

get imageMeta(): ImageMeta | undefined {
if (this.layout === Layout.RESPONSIVE) {
return this.responsiveImage.getImageMetaBySize(
this.args.image,
this.args.size
);
} else {
return this.responsiveImage.getImageMetaByWidth(
this.args.image,
this.width ?? 0
);
}
}

/**
* the image source which fits at best for the size and screen
*/
get src(): string | undefined {
return this.args.image
? this.responsiveImage.getImageBySize(this.args.image, this.args.size)
: undefined;
return this.imageMeta?.image;
}

get width(): number | undefined {
if (this.layout === Layout.RESPONSIVE) {
return this.imageMeta?.width;
} else {
if (this.args.width) {
return this.args.width;
}

const ar = this.responsiveImage.getAspectRatio(this.args.image);
if (ar !== undefined && ar !== 0 && this.args.height !== undefined) {
return this.args.height * ar;
}

return undefined;
}
}

get height(): number | undefined {
if (this.layout === Layout.RESPONSIVE) {
return this.imageMeta?.height;
} else {
if (this.args.height) {
return this.args.height;
}

const ar = this.responsiveImage.getAspectRatio(this.args.image);
if (ar !== undefined && ar !== 0 && this.args.width !== undefined) {
return this.args.width / ar;
}

return undefined;
}
}
}
22 changes: 0 additions & 22 deletions addon/helpers/responsive-image-resolve.js

This file was deleted.

24 changes: 24 additions & 0 deletions addon/helpers/responsive-image-resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { inject as service } from '@ember/service';
import { htmlSafe } from '@ember/string';
import Helper from '@ember/component/helper';
import ResponsiveImageService from 'ember-responsive-image/services/responsive-image';

/**
* @class responsiveImageResolve
* @namespace Helpers
* @extends Ember.Helper
* @public
*/
export default class ResponsiveImageResolve extends Helper {
@service
responsiveImage!: ResponsiveImageService;

compute(
[image, size]: [string, number | undefined] /*, hash*/
): ReturnType<typeof htmlSafe> | undefined {
const responsive = this.responsiveImage.getImageMetaBySize(image, size)
?.image;

return responsive ? htmlSafe(responsive) : undefined;
}
}
113 changes: 26 additions & 87 deletions addon/services/responsive-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,42 +20,23 @@ export interface Meta {

/**
* Service class to provides images generated by the responsive images package
*
* @class ResponsiveImageService
* @extends Service
* @module responsive-image
* @public
*/
export default class ResponsiveImageService extends Service {
/**
* the screen's width
* This is the base value to calculate the image size.
* That means the {{#crossLink "Services/ResponsiveImage:getImageBySize"}}getImageBySize{{/crossLink}} will return
* an image that's close to `screenWidth * window.devicePixelRatio * size / 100`
*
* @property screenWidth
* @type {Number}
* @public
*/
screenWidth = screenWidth;

/**
* the physical width
*
* @property physicalWidth
* @type {Number}
* @public
*/
physicalWidth = this.screenWidth * ((window && window.devicePixelRatio) || 1);

/**
* return the images with the different widths
*
* @method getImages
* @param {String} imageName The origin name of the Image
* @param {String} type The image type (jpeg, png, webp etc.). Optional, leave undefined to get all images
* @returns {Array} An array of objects with the image name and width, e.g. [{ width: 40, height: 20, image: myImage40w.jpg}, ...]
* @public
*/
getImages(imageName: string, type?: ImageType): ImageMeta[] {
assert(
Expand Down Expand Up @@ -90,85 +71,47 @@ export default class ResponsiveImageService extends Service {
}

/**
* returns the image which fits for given size
*
* @method getImageBySize
* @param {String} imageName The origin name of the Image
* @param {Number} size The width of the image in percent of the screenwidth
* @param {String} type The image type (jpeg, png, webp etc.)
* @return {String} The url of the image
* @public
* returns the image data which fits for given size (in vw)
*/
getImageBySize(
getImageMetaBySize(
imageName: string,
size?: number,
type?: ImageType
): string | undefined {
return this.getImageDataBySize(imageName, size, type)?.image;
type: ImageType = this.getType(imageName)
): ImageMeta | undefined {
const width = this.getDestinationWidthBySize(size ?? 0);
return this.getImageMetaByWidth(imageName, width, type);
}

/**
* returns the image data which fits for given size
*
* @method getImageDataBySize
* @param {String} imageName The origin name of the Image
* @param {Number} size The width of the image in percent of the screenwidth
* @param {String} type The image type (jpeg, png, webp etc.)
* @return {Object} The data with image,width and height
* @public
* returns the image data which fits for given width (in px)
*/
getImageDataBySize(
getImageMetaByWidth(
imageName: string,
size?: number,
width: number,
type: ImageType = this.getType(imageName)
): ImageMeta | undefined {
const width = this.getDestinationImageWidthBySize(imageName, size);
return this.getImages(imageName).find(
(img) => img.width === width && img.type === type
);
return this.getImages(imageName)
.filter((img) => img.type === type)
.reduce((prevValue: ImageMeta | undefined, imageMeta: ImageMeta) => {
if (prevValue === undefined) {
return imageMeta;
}

if (imageMeta.width >= width && prevValue.width >= width) {
return imageMeta.width >= prevValue.width ? prevValue : imageMeta;
} else {
return imageMeta.width >= prevValue.width ? imageMeta : prevValue;
}
}, undefined);
}

/**
* returns the closest supported width to the screenwidth and size
*
* @method getDestinationImageWidthBySize
* @param {String} imageName The name of the image
* @param {Number} size The width of the image in percent of the screenwidth
* @return {Number} the supported width
* @private
*/
getDestinationImageWidthBySize(imageName: string, size = 100): number {
const destinationWidth = this.getDestinationWidthBySize(size);
return this.getSupportedWidths(imageName).reduce((prevValue, item) => {
if (item >= destinationWidth && prevValue >= destinationWidth) {
return item >= prevValue ? prevValue : item;
} else {
return item >= prevValue ? item : prevValue;
}
}, 0);
}
getAspectRatio(imageName: string): number | undefined {
const meta = this.getImages(imageName)[0];

/**
* returns the supported widths of an image
*
* @method getSupportedWidths
* @param {String} imageName the name of the image
* @return {Number[]} the supported widths
* @private
*/
getSupportedWidths(imageName: string): number[] {
return this.getImages(imageName).map((item) => {
return item.width;
});
return meta ? meta.width / meta.height : undefined;
}

/**
* @method getDestinationWidthBySize
* @param {Number} size returns the physical width factored by size
* @returns {Number}
* @private
*/
getDestinationWidthBySize(size: number): number {
private getDestinationWidthBySize(size: number): number {
const physicalWidth = this.physicalWidth;
const factor = (size || 100) / 100;

Expand All @@ -179,10 +122,6 @@ export default class ResponsiveImageService extends Service {

/**
* the meta values from build time
*
* @property meta
* @type {object}
* @private
*/
get meta(): Record<string, Meta> {
if (this._meta) {
Expand Down
4 changes: 4 additions & 0 deletions addon/styles/ember-responsive-image.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.eri-responsive {
width: 100%;
height: auto;
}
8 changes: 8 additions & 0 deletions tests/dummy/app/templates/index.hbs
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
<h2>Fixed</h2>

<ResponsiveImage @width={{200}} @image="assets/images/test.png"/>

<h2>Responsive</h2>

<ResponsiveImage @image="assets/images/test.png"/>


Loading

0 comments on commit a889521

Please sign in to comment.