Files
ionic-framework/src/components/img/img.ts
Adam Bradley 7000b1b173 refactor: improve tree shaking abilities
Internal refactor completed in order to improve tree shaking and dead
code removal. The public API, with an exception to ion-slides, has
stayed the same. However, internally many changes were required so
bundlers could better exclude modules which should not be bundled.
Ultimately most changes resorted to removing references to `window` or
`document`, or a module that referenced one of those.

BREAKING CHANGES

ion-slides was refactored to remove the external dependencies, and
rewritten in TypeScript/ES6 modules to again improve tree shaking
abilities.
2017-01-09 09:51:39 -06:00

398 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ChangeDetectionStrategy, Component, ElementRef, Input, NgZone, OnDestroy, Optional, Renderer, ViewEncapsulation } from '@angular/core';
import { Content } from '../content/content';
import { DomController } from '../../platform/dom-controller';
import { isPresent, isTrueProperty } from '../../util/util';
import { Platform } from '../../platform/platform';
/**
* @name Img
* @description
* Two of the biggest cuprits of scroll jank is starting up a new HTTP
* request, and rendering images. These two reasons is largely why
* `ion-img` was created. The standard HTML `img` element is often a large
* source of these problems, and what makes matters worse is that the app
* does not have fine-grained control of requests and rendering for each
* `img` element.
*
* The `ion-img` component is similar to the standard `img` element,
* but it also adds features in order to provide improved performance.
* Features include only loading images which are visible, using web workers
* for HTTP requests, preventing jank while scrolling and in-memory caching.
*
* Note that `ion-img` also comes with a few more restrictions in comparison
* to the standard `img` element. A good rule is, if there are only a few
* images to be rendered on a page, then the standard `img` is probably
* best. However, if a page has the potential for hundreds or even thousands
* of images within a scrollable area, then `ion-img` would be better suited
* for the job.
*
*
* ### Lazy Loading
*
* Lazy loading images refers to only loading images which are actually
* visible within the user's viewport. This also means that images which are
* not viewable on the initial load would not be downloaded or rendered. Next,
* as the user scrolls, each image which becomes visible is then requested
* then rendered on-demand.
*
* The benefits of this approach is that unnecessary and resource intensive
* HTTP requests are not started, valuable bandwidth isn't wasted, and this
* allows the browser to free up resources which would be wasted on images
* which are not even viewable. For example, animated GIFs are enourmous
* performance drains, however, with `ion-img` the app is able to dedicate
* resources to just the viewable images. But again, if the problems listed
* above are not problems within your app, then the standard `img` element
* may be best.
*
*
* ### Image Dimensions
*
* By providing image dimensions up front, Ionic is able to accurately size
* up the image's location within the viewport, which helps lazy load only
* images which are viewable. Image dimensions can either by set as
* properties, inline styles, or external stylesheets. It doesn't matter
* which method of setting dimensions is used, but it's important that somehow
* each `ion-img` has been given an exact size.
*
* For example, by default `<ion-avatar>` and `<ion-thumbnail>` already come
* with exact sizes when placed within an `<ion-item>`. By giving each image
* an exact size, this then further locks in the size of each `ion-item`,
* which again helps improve scroll performance.
*
* ```html
* <!-- dimensions set using attributes -->
* <ion-img width="80" height="80" src="..."></ion-img>
*
* <!-- dimensions set using input properties -->
* <ion-img [width]="imgWidth" [height]="imgHeight" src="..."></ion-img>
*
* <!-- dimensions set using inline styles -->
* <ion-img style="width: 80px; height: 80px;" src="..."></ion-img>
* ```
*
* Additionally, each `ion-img` uses the `object-fit: cover` CSS property.
* What this means is that the actual rendered image will center itself within
* it's container. Or to really get detailed: The image is sized to maintain
* its aspect ratio while filling the containing elements entire content box.
* Its concrete object size is resolved as a cover constraint against the
* elements used width and height.
*
* ### Future Optimizations
*
* Future goals are to place image requests within web workers, and cache
* images in-memory as datauris. This method has proven to be effective,
* however there are some current limitations with Cordova which we are
* currently working on.
*
*/
@Component({
selector: 'ion-img',
template: '<img>',
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
})
export class Img implements OnDestroy {
/** @internal */
_src: string;
/** @internal */
_requestingSrc: string;
/** @internal */
_renderedSrc: string;
/** @internal */
_hasLoaded: boolean;
/** @internal */
_cache: boolean = true;
/** @internal */
_bounds: any;
/** @internal */
_rect: any;
/** @internal */
_w: string = '';
/** @internal */
_h: string = '';
/** @internal */
_wQ: string = '';
/** @internal */
_hQ: string = '';
/** @internal */
_img: HTMLImageElement;
/** @internal */
_unreg: Function;
/** @private */
canRequest: boolean;
/** @private */
canRender: boolean;
constructor(
private _elementRef: ElementRef,
private _renderer: Renderer,
private _plt: Platform,
private _zone: NgZone,
@Optional() private _content: Content,
private _dom: DomController
) {
if (!this._content) {
console.warn(`ion-img can only be used within an ion-content`);
} else {
this._content.addImg(this);
}
this._isLoaded(false);
}
/**
* @input {string} Image src.
*/
@Input()
get src(): string {
return this._src;
}
set src(newSrc: string) {
// if the source hasn't changed, then um, let's not change it
if (newSrc !== this._src) {
// we're changing the source
// so abort any active http requests
// and render the image empty
this.reset();
// update to the new src
this._src = newSrc;
if (newSrc.indexOf('data:') === 0) {
// they're using an actual datauri already
this._hasLoaded = true;
} else {
// reset any existing datauri we might be holding onto
this._hasLoaded = false;
}
// run update to kick off requests or render if everything is good
this.update();
}
}
/**
* @private
*/
reset() {
if (this._requestingSrc) {
// abort any active requests
console.debug(`abortRequest ${this._requestingSrc} ${Date.now()}`);
this._srcAttr('');
this._requestingSrc = null;
}
if (this._renderedSrc) {
// clear out the currently rendered img
console.debug(`clearRender ${this._renderedSrc} ${Date.now()}`);
this._renderedSrc = null;
this._isLoaded(false);
}
}
/**
* @private
*/
update() {
// only attempt an update if there is an active src
// and the content containing the image considers it updatable
if (this._src && this._content.isImgsUpdatable()) {
if (this.canRequest && (this._src !== this._renderedSrc && this._src !== this._requestingSrc) && !this._hasLoaded) {
// only begin the request if we "can" request
// begin the image request if the src is different from the rendered src
// and if we don't already has a tmpDataUri
console.debug(`request ${this._src} ${Date.now()}`);
this._requestingSrc = this._src;
this._isLoaded(false);
this._srcAttr(this._src);
// set the dimensions of the image if we do have different data
this._setDims();
}
if (this.canRender && this._hasLoaded && this._src !== this._renderedSrc) {
// we can render and we have a datauri to render
this._renderedSrc = this._src;
this._setDims();
this._dom.write(() => {
if (this._hasLoaded) {
console.debug(`render ${this._src} ${Date.now()}`);
this._isLoaded(true);
this._srcAttr(this._src);
}
});
}
}
}
/**
* @internal
*/
_isLoaded(isLoaded: boolean) {
const renderer = this._renderer;
const ele = this._elementRef.nativeElement;
renderer.setElementClass(ele, 'img-loaded', isLoaded);
renderer.setElementClass(ele, 'img-unloaded', !isLoaded);
}
/**
* @internal
*/
_srcAttr(srcAttr: string) {
const imgEle = this._img;
const renderer = this._renderer;
if (imgEle.src !== srcAttr) {
renderer.setElementAttribute(this._img, 'src', srcAttr);
renderer.setElementAttribute(this._img, 'alt', this.alt);
}
}
/**
* @private
*/
get top(): number {
const bounds = this._getBounds();
return bounds && bounds.top || 0;
}
/**
* @private
*/
get bottom(): number {
const bounds = this._getBounds();
return bounds && bounds.bottom || 0;
}
private _getBounds() {
if (this._bounds) {
// we've been manually passed bounds data
// this is probably from Virtual Scroll items
return this._bounds;
}
if (!this._rect) {
// we don't have bounds from virtual scroll
// so let's do the raw DOM lookup w/ getBoundingClientRect
this._rect = (<HTMLElement>this._elementRef.nativeElement).getBoundingClientRect();
console.debug(`img, ${this._src}, read, ${this._rect.top} - ${this._rect.bottom}`);
}
return this._rect;
}
/**
* @input {any} Sets the bounding rectangle of the element relative to the viewport.
* When using `VirtualScroll`, each virtual item should pass its bounds to each
* `ion-img`. The passed in data object should include `top` and `bottom` properties.
*/
@Input()
set bounds(b: any) {
if (isPresent(b)) {
this._bounds = b;
}
}
/**
* @input {boolean} After an image has been successfully downloaded, it can be cached
* in-memory. This is useful for `VirtualScroll` by allowing image responses to be
* cached, and not rendered, until after scrolling has completed, which allows for
* smoother scrolling.
*/
@Input()
get cache(): boolean {
return this._cache;
}
set cache(val: boolean) {
this._cache = isTrueProperty(val);
}
/**
* @input {string} Image width. If this property is not set it's important that
* the dimensions are still set using CSS. If the dimension is just a number it
* will assume the `px` unit.
*/
@Input()
set width(val: string | number) {
this._wQ = getUnitValue(val);
this._setDims();
}
/**
* @input {string} Image height. If this property is not set it's important that
* the dimensions are still set using CSS. If the dimension is just a number it
* will assume the `px` unit.
*/
@Input()
set height(val: string | number) {
this._hQ = getUnitValue(val);
this._setDims();
}
private _setDims() {
// only set the dimensions if we can render
// and only if the dimensions have changed from when we last set it
if (this.canRender && (this._w !== this._wQ || this._h !== this._hQ)) {
var wrapperEle: HTMLImageElement = this._elementRef.nativeElement;
var renderer = this._renderer;
this._dom.write(() => {
if (this._w !== this._wQ) {
this._w = this._wQ;
renderer.setElementStyle(wrapperEle, 'width', this._w);
}
if (this._h !== this._hQ) {
this._h = this._hQ;
renderer.setElementStyle(wrapperEle, 'height', this._h);
}
});
}
}
/**
* @input {string} Set the `alt` attribute which gets assigned to
* the inner `img` element.
*/
@Input() alt: string = '';
/**
* @private
*/
ngAfterContentInit() {
this._img = this._elementRef.nativeElement.firstChild;
this._unreg = this._plt.registerListener(this._img, 'load', () => {
this._hasLoaded = true;
this.update();
}, { passive: true });
}
/**
* @private
*/
ngOnDestroy() {
this._unreg && this._unreg();
this._content && this._content.removeImg(this);
}
}
function getUnitValue(val: any): string {
if (isPresent(val)) {
if (typeof val === 'string') {
if (val.indexOf('%') > -1 || val.indexOf('px') > -1) {
return val;
}
if (val.length) {
return val + 'px';
}
} else if (typeof val === 'number') {
return val + 'px';
}
}
return '';
}