fix(img): use img tag due to cordova limitations

This commit is contained in:
Adam Bradley
2016-12-14 14:17:16 -06:00
parent 1f83cde78b
commit 4f61ea5f9b
4 changed files with 37 additions and 322 deletions

View File

@ -1,175 +0,0 @@
export class ImgLoader {
private imgs: ImgData[] = [];
load(src: string, useCache: boolean, callback: ImgLoadCallback) {
// see if we already have image data for this src
let img = this.imgs.find(i => i.src === src);
if (img && img.datauri && useCache) {
// we found image data, and it's cool if we use the cache
// so let's respond with the cached data
callback(200, null, img.datauri);
return;
}
// so no cached image data, so we'll
// need to do a new http request
if (img && img.xhr && img.xhr.readyState !== 4) {
// looks like there's already an active http request going on
// for this same source, so let's just add another listener
img.xhr.addEventListener('load', (xhrEvent) => {
const target: any = xhrEvent.target;
const contentType = target.getResponseHeader('Content-Type');
onXhrLoad(callback, target.status, contentType, target.response, useCache, img, this.imgs);
});
img.xhr.addEventListener('error', (xhrErrorEvent) => {
onXhrError(callback, img, xhrErrorEvent);
});
return;
}
if (!img) {
// no image data yet, so let's create it
img = { src: src, len: 0 };
this.imgs.push(img);
}
// ok, let's do a full request for the image
img.xhr = new XMLHttpRequest();
img.xhr.open('GET', src, true);
img.xhr.responseType = 'arraybuffer';
// add the listeners if it loaded or errored
img.xhr.addEventListener('load', (xhrEvent) => {
const target: any = xhrEvent.target;
const contentType = target.getResponseHeader('Content-Type');
onXhrLoad(callback, target.status, contentType, target.response, useCache, img, this.imgs);
});
img.xhr.addEventListener('error', (xhrErrorEvent) => {
onXhrError(callback, img, xhrErrorEvent);
});
// awesome, let's kick off the request
img.xhr.send();
}
abort(src: string) {
const img = this.imgs.find(i => i.src === src);
if (img && img.xhr && img.xhr.readyState !== 4) {
// we found the image data and there's an active
// http request, so let's abort the request
img.xhr.abort();
img.xhr = null;
}
}
}
export function onXhrLoad(callback: ImgLoadCallback, status: number, contentType: string, responseData: ArrayBuffer, useCache: boolean, img: ImgData, imgs: ImgData[]) {
if (!callback) {
return null;
}
// the http request has been loaded
// create a rsp object to send back to the main thread
let datauri: string = null;
if (status === 200) {
// success!!
// now let's convert the response arraybuffer data into a datauri
datauri = getDataUri(contentType, responseData);
if (useCache) {
// if the image was successfully downloaded
// and this image is allowed to be cached
// then let's add it to our image data for later use
img.datauri = datauri;
img.len = datauri.length;
cleanCache(imgs, CACHE_LIMIT);
}
}
// fire the callback with what we've learned today
callback(status, null, datauri);
}
export function cleanCache(imgs: ImgData[], cacheLimit: number) {
// let's loop through all our cached data and if we go
// over our limit then let's clean it out a bit
// oldest data should go first
let cacheSize = 0;
for (var i = imgs.length - 1; i >= 0; i--) {
cacheSize += imgs[i].len;
if (cacheSize > cacheLimit) {
console.debug(`img-loader, clear cache`);
imgs.splice(0, i + 1);
break;
}
}
}
function onXhrError(callback: ImgLoadCallback, imgData: ImgData, err: ErrorEvent) {
// darn, we got an error!
callback && callback(0, (err.message || ''), null);
imgData.xhr = null;
}
function getDataUri(contentType, arrayBuffer): string {
// take arraybuffer and content type and turn it into
// a datauri string that can be used by <img>
const rtn: string[] = ['data:' + contentType + ';base64,'];
const bytes = new Uint8Array(arrayBuffer);
const byteLength = bytes.byteLength;
const byteRemainder = byteLength % 3;
const mainLength = byteLength - byteRemainder;
let i, a, b, c, d, chunk;
for (i = 0; i < mainLength; i = i + 3) {
chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
a = (chunk & 16515072) >> 18;
b = (chunk & 258048) >> 12;
c = (chunk & 4032) >> 6;
d = chunk & 63;
rtn.push(ENCODINGS[a] + ENCODINGS[b] + ENCODINGS[c] + ENCODINGS[d]);
}
if (byteRemainder === 1) {
chunk = bytes[mainLength];
a = (chunk & 252) >> 2;
b = (chunk & 3) << 4;
rtn.push(ENCODINGS[a] + ENCODINGS[b] + '==');
} else if (byteRemainder === 2) {
chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
a = (chunk & 64512) >> 10;
b = (chunk & 1008) >> 4;
c = (chunk & 15) << 2;
rtn.push(ENCODINGS[a] + ENCODINGS[b] + ENCODINGS[c] + '=');
}
return rtn.join('');
}
// used by the setData function
const ENCODINGS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
const CACHE_LIMIT = 1381855 * 20;
export interface ImgData {
src: string;
datauri?: string;
len?: number;
xhr?: XMLHttpRequest;
}
export type ImgLoadCallback = {
(status: number, msg: string, datauri: string): void;
}

View File

@ -2,8 +2,8 @@ import { ChangeDetectionStrategy, Component, ElementRef, Input, NgZone, OnDestro
import { Content } from '../content/content';
import { DomController } from '../../util/dom-controller';
import { ImgLoader, ImgLoadCallback } from './img-loader';
import { isPresent, isTrueProperty } from '../../util/util';
import { listenEvent, eventOptions } from '../../util/ui-event-manager';
import { Platform } from '../../platform/platform';
@ -80,40 +80,12 @@ import { Platform } from '../../platform/platform';
* Its concrete object size is resolved as a cover constraint against the
* elements used width and height.
*
* ### Future Optimizations
*
* ### Web Worker and XHR Requests
*
* Another big cause of scroll jank is kicking off a new HTTP request,
* which is exactly what images do. Normally, this isn't a problem for
* something like a blog since all image HTTP requests are started immediately
* as HTML parses. However, Ionic has the ability to include hundreds, or even
* thousands of images within one page, but its not actually loading all of
* the images at the same time.
*
* Imagine an app where users can scroll slowly, or very quickly, through
* thousands of images. If they're scrolling extremely fast, ideally the app
* wouldn't want to start all of those image requests, but if they're scrolling
* slowly they would. Additionally, most browsers can only have six requests at
* one time for the same domain, so it's extemely important that we're managing
* exacctly which images we should downloading. Basically we want to ensure
* that the app is requesting the most important images, and aborting
* unnecessary requests, which is another benefit of using `ion-img`.
*
* Next, by running the image request within a web worker, we're able to pass
* off the heavy lifting to another thread. Not only are able to take the load
* of the main thread, but we're also able to accurately control exactly which
* images should be downloading, along with the ability to abort unnecessary
* requests. Aborting requets is just as important so that Ionic can free up
* connections for the most important images which are visible.
*
* One restriction however, is that all image requests must work with
* [cross-origin HTTP requests (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS).
* Traditionally, the `img` element does not have this issue, but because
* `ion-img` uses `XMLHttpRequest` within a web worker, then requests for
* images must be served from the same domain, or the image server's response
* must set the `Access-Control-Allow-Origin` HTTP header. Again, if your app
* does not have the same problems which `ion-img` is solving, then it's
* recommended to just use the standard `img` HTML element instead.
* 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({
@ -130,12 +102,10 @@ export class Img implements OnDestroy {
/** @internal */
_renderedSrc: string;
/** @internal */
_tmpDataUri: string;
_hasLoaded: boolean;
/** @internal */
_cache: boolean = true;
/** @internal */
_cb: ImgLoadCallback;
/** @internal */
_bounds: any;
/** @internal */
_rect: any;
@ -147,6 +117,10 @@ export class Img implements OnDestroy {
_wQ: string = '';
/** @internal */
_hQ: string = '';
/** @internal */
_img: HTMLImageElement;
/** @internal */
_unreg: Function;
/** @private */
canRequest: boolean;
@ -155,7 +129,6 @@ export class Img implements OnDestroy {
constructor(
private _ldr: ImgLoader,
private _elementRef: ElementRef,
private _renderer: Renderer,
private _platform: Platform,
@ -191,11 +164,11 @@ export class Img implements OnDestroy {
if (newSrc.indexOf('data:') === 0) {
// they're using an actual datauri already
this._tmpDataUri = newSrc;
this._hasLoaded = true;
} else {
// reset any existing datauri we might be holding onto
this._tmpDataUri = null;
this._hasLoaded = false;
}
// run update to kick off requests or render if everything is good
@ -210,7 +183,7 @@ export class Img implements OnDestroy {
if (this._requestingSrc) {
// abort any active requests
console.debug(`abortRequest ${this._requestingSrc} ${Date.now()}`);
this._ldr.abort(this._requestingSrc);
this._srcAttr('');
this._requestingSrc = null;
}
if (this._renderedSrc) {
@ -228,61 +201,34 @@ export class Img implements OnDestroy {
// 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._tmpDataUri) {
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._cb = (status, msg, datauri) => {
this._loadResponse(status, msg, datauri);
this._cb = null;
};
// post the message to the web worker
this._ldr.load(this._src, this._cache, this._cb);
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._tmpDataUri && this._src !== this._renderedSrc) {
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._tmpDataUri) {
if (this._hasLoaded) {
console.debug(`render ${this._src} ${Date.now()}`);
this._isLoaded(true);
this._srcAttr(this._tmpDataUri);
this._tmpDataUri = null;
}
});
}
}
}
private _loadResponse(status: number, msg: string, datauri: string) {
this._requestingSrc = null;
if (status === 200) {
// success :)
this._tmpDataUri = datauri;
this.update();
} else {
// error :(
if (status) {
console.error(`img, status: ${status} ${msg}`);
}
this._renderedSrc = this._tmpDataUri = null;
this._dom.write(() => {
this._isLoaded(false);
});
}
}
/**
* @internal
*/
@ -297,11 +243,10 @@ export class Img implements OnDestroy {
* @internal
*/
_srcAttr(srcAttr: string) {
const imgEle = this._elementRef.nativeElement.firstChild;
const renderer = this._renderer;
renderer.setElementAttribute(imgEle, 'src', srcAttr);
renderer.setElementAttribute(imgEle, 'alt', this.alt);
renderer.setElementAttribute(this._img, 'src', srcAttr);
renderer.setElementAttribute(this._img, 'alt', this.alt);
}
/**
@ -409,11 +354,25 @@ export class Img implements OnDestroy {
*/
@Input() alt: string = '';
/**
* @private
*/
ngAfterContentInit() {
this._img = this._elementRef.nativeElement.firstChild;
this._unreg && this._unreg();
const opts = eventOptions(false, true);
this._unreg = listenEvent(this._img, 'load', false, opts, () => {
this._hasLoaded = true;
this.update();
});
}
/**
* @private
*/
ngOnDestroy() {
this._cb = null;
this._unreg && this._unreg();
this._content && this._content.removeImg(this);
}

View File

@ -1,55 +1,12 @@
import { ElementRef, Renderer } from '@angular/core';
import { Content } from '../../content/content';
import { Img } from '../img';
import { ImgLoader, ImgData, ImgLoadCallback, cleanCache, onXhrLoad } from '../img-loader';
import { mockContent, MockDomController, mockElementRef, mockPlatform, mockRenderer, mockZone } from '../../../util/mock-providers';
import { Platform } from '../../../platform/platform';
describe('Img', () => {
describe('cleanCache', () => {
it('should clean out oldest img data when passing cache limit', () => {
const imgs: ImgData[] = [
{ src: 'img1.jpg', len: 100 },
{ src: 'img2.jpg', len: 0 },
{ src: 'img3.jpg', len: 100 },
{ src: 'img4.jpg', len: 100 },
];
cleanCache(imgs, 100);
expect(imgs.length).toEqual(1);
expect(imgs[0].src).toEqual('img4.jpg');
});
});
describe('onXhrLoad', () => {
it('should cache img response', () => {
const callback: ImgLoadCallback = () => {};
const status = 200;
const contentType = 'image/jpeg';
const responseData = new ArrayBuffer(0);
const useCache = true;
const imgData: ImgData = {
src: 'image.jpg'
};
const imgs: ImgData[] = [];
onXhrLoad(callback, status, contentType, responseData, useCache, imgData, imgs);
expect(imgData.datauri).toEqual('data:image/jpeg;base64,');
expect(imgData.len).toEqual(imgData.datauri.length);
});
it('should do nothing when theres no callback', () => {
const r = onXhrLoad(null, 0, 'image/jpeg', new ArrayBuffer(0), true, null, null);
expect(r).toEqual(null);
});
});
describe('reset', () => {
it('should clear rendering src', () => {
@ -60,35 +17,14 @@ describe('Img', () => {
expect(img._renderedSrc).toEqual(null);
});
it('should abort requesting src', () => {
spyOn(ldr, 'abort');
img._requestingSrc = '_requestingSrc.jpg';
img.reset();
expect(ldr.abort).toHaveBeenCalledWith('_requestingSrc.jpg');
expect(img._requestingSrc).toEqual(null);
});
});
describe('src setter', () => {
it('should abort request if already requesting', () => {
spyOn(img, 'reset');
img._requestingSrc = 'requesting.jpg';
img._tmpDataUri = 'tmpDatauri.jpg';
img.src = 'image.jpg';
expect(img.reset).toHaveBeenCalled();
expect(img.src).toEqual('image.jpg');
expect(img._tmpDataUri).toEqual(null);
});
it('should set datauri src', () => {
spyOn(img, 'update');
img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAAAAACH5BAAAAAAALAAAAAABAAEAAAICTAEAOw==';
expect(img.src).toEqual('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAAAAACH5BAAAAAAALAAAAAABAAEAAAICTAEAOw==');
expect(img._tmpDataUri).toEqual(`data:image/gif;base64,R0lGODlhAQABAIAAAAAAAAAAACH5BAAAAAAALAAAAAABAAEAAAICTAEAOw==`);
expect(img.update).toHaveBeenCalled();
});
@ -112,7 +48,6 @@ describe('Img', () => {
let img: Img;
let ldr: ImgLoader;
let elementRef: ElementRef;
let renderer: Renderer;
let platform: Platform;
@ -121,12 +56,11 @@ describe('Img', () => {
beforeEach(() => {
content = mockContent();
ldr = new ImgLoader();
elementRef = mockElementRef();
renderer = mockRenderer();
platform = mockPlatform();
dom = new MockDomController();
img = new Img(ldr, elementRef, renderer, platform, mockZone(), content, dom);
img = new Img(elementRef, renderer, platform, mockZone(), content, dom);
});
});

View File

@ -19,7 +19,6 @@ import { Events, setupProvideEvents } from './util/events';
import { Form } from './util/form';
import { GestureController } from './gestures/gesture-controller';
import { Haptic } from './util/haptic';
import { ImgLoader } from './components/img/img-loader';
import { IonicGestureConfig } from './gestures/gesture-config';
import { Keyboard } from './util/keyboard';
import { LoadingController } from './components/loading/loading';
@ -56,7 +55,6 @@ export { Config, setupConfig, ConfigToken } from './config/config';
export { DomController, DomCallback } from './util/dom-controller';
export { Platform, setupPlatform, UserAgentToken, DocumentDirToken, DocLangToken, NavigatorPlatformToken } from './platform/platform';
export { Haptic } from './util/haptic';
export { ImgLoader } from './components/img/img-loader';
export { QueryParams, setupQueryParams, UrlToken } from './platform/query-params';
export { DeepLinker } from './navigation/deep-linker';
export { NavController } from './navigation/nav-controller';
@ -176,7 +174,6 @@ export class IonicModule {
Form,
GestureController,
Haptic,
ImgLoader,
Keyboard,
LoadingController,
Location,