├── .gitattributes
├── .codeclimate.yml
├── .bowerrc
├── .gitignore
├── example
├── images
│ ├── angular.png
│ ├── angular@2x.png
│ └── angular-no-hdpi.png
├── angular-retina-example-app.js
└── index.html
├── lib
├── .jshintrc
└── angular-retina.js
├── .travis.yml
├── .editorconfig
├── bower.json
├── LICENSE
├── karma.conf.js
├── test
└── unit
│ ├── ngRetina-loadErrorHandler.js
│ ├── ngRetina-fadeInWhenLoaded.js
│ └── ngRetina.js
├── Gruntfile.js
├── package.json
└── README.md
/.gitattributes:
--------------------------------------------------------------------------------
1 | /dist/ -crlf -diff
2 |
--------------------------------------------------------------------------------
/.codeclimate.yml:
--------------------------------------------------------------------------------
1 | exclude_paths:
2 | - build/**/*
3 |
--------------------------------------------------------------------------------
/.bowerrc:
--------------------------------------------------------------------------------
1 | {
2 | "directory": "bower_components/"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /bower_components
3 | /coverage
4 | /.idea/
5 |
--------------------------------------------------------------------------------
/example/images/angular.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/angular-retina/HEAD/example/images/angular.png
--------------------------------------------------------------------------------
/example/images/angular@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/angular-retina/HEAD/example/images/angular@2x.png
--------------------------------------------------------------------------------
/example/images/angular-no-hdpi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jrief/angular-retina/HEAD/example/images/angular-no-hdpi.png
--------------------------------------------------------------------------------
/lib/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "curly": true,
3 | "eqeqeq": true,
4 | "immed": true,
5 | "latedef": true,
6 | "newcap": true,
7 | "noarg": true,
8 | "sub": true,
9 | "undef": true,
10 | "boss": true,
11 | "eqnull": true,
12 | "predef": ["exports"]
13 | }
14 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 | language: node_js
3 |
4 | node_js:
5 | - 4
6 |
7 | before_script:
8 | - export DISPLAY=:99.0
9 | - sh -e /etc/init.d/xvfb start
10 | - npm install -g grunt-cli bower
11 | - bower install
12 | - npm install
13 |
14 | script:
15 | - npm run test-travis
16 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [{package.json,*.yml}]
12 | indent_style = space
13 | indent_size = 2
14 |
15 | [*.md]
16 | trim_trailing_whitespace = false
17 |
--------------------------------------------------------------------------------
/example/angular-retina-example-app.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | 'use strict';
3 |
4 | angular.module('angularRetinaExampleApp', ['ngRetina'])
5 | .controller('AngularRetinaExampleController', AngularRetinaExampleController);
6 |
7 | function AngularRetinaExampleController() {
8 | var vm = this;
9 | vm.image = 'images/angular.png';
10 | }
11 | })();
12 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "angular-retina",
3 | "description": "Replace AngularJS directive 'ng-src' by a version which supports Retina displays",
4 | "version": "0.3.13",
5 | "main": "build/angular-retina.js",
6 | "author": {
7 | "name": "Jacob Rief",
8 | "email": "jacob.rief@gmail.com"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "git://github.com/jrief/angular-retina.git"
13 | },
14 | "dependencies": {
15 | "angular": ">= 1.5"
16 | },
17 | "ignore": [
18 | "lib",
19 | "test"
20 | ],
21 | "devDependencies": {
22 | "angular-mocks": "1.5",
23 | "angular": "1.5"
24 | },
25 | "license": "MIT",
26 | "resolutions": {
27 | "angular": "1.5"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Jacob Rief
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/example/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | angular-retina example
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
![Angular JS]()
19 |
20 |
21 |
22 |
23 |
24 |
![Angular JS]()
25 |
26 |
27 |
28 |
29 |
30 |
![Angular JS]()
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | var testMinified = process.argv.indexOf('--min') > -1,
2 | subject;
3 |
4 | if (testMinified) {
5 | subject = 'build/angular-retina.min.js';
6 | console.log('Testing minifed angular-retina');
7 | } else {
8 | subject = 'lib/angular-retina.js';
9 | }
10 |
11 | module.exports = function(config) {
12 | config.set({
13 | basePath: '',
14 | frameworks: ['jasmine'],
15 | files: [
16 | 'bower_components/angular/angular.js',
17 | 'bower_components/angular-mocks/angular-mocks.js',
18 | subject,
19 | 'test/unit/**/*.js'
20 | ],
21 | port: 9877,
22 | colors: true,
23 | logLevel: config.LOG_INFO,
24 | autoWatch: false,
25 | browsers: ['PhantomJS'],
26 | singleRun: false,
27 | reporters: ['dots', 'coverage'],
28 | preprocessors: {
29 | 'lib/angular-retina.js': ['coverage']
30 | },
31 | plugins: [
32 | 'karma-phantomjs-launcher',
33 | 'karma-chrome-launcher',
34 | 'karma-firefox-launcher',
35 | 'karma-coverage',
36 | 'karma-jasmine'
37 | ],
38 | coverageReporter: {
39 | reporters: [{
40 | type: 'html',
41 | subdir: 'report-html'
42 | }, {
43 | type: 'lcov',
44 | subdir: 'report-lcov'
45 | },
46 | {
47 | type: 'text-summary',
48 | subdir: '.',
49 | file: 'text-summary.txt'
50 | }]
51 | }
52 |
53 | });
54 | };
55 |
--------------------------------------------------------------------------------
/test/unit/ngRetina-loadErrorHandler.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | describe('when loadErrorHandler is set, it should be called when there is a load error', function() {
4 |
5 | beforeEach(function() {
6 | module(function($provide) {
7 | $provide.provider('$window', function() {
8 | this.$get = function() {
9 | try {
10 | window.devicePixelRatio = 2;
11 | } catch (TypeError) {
12 | // in Firefox window.devicePixelRatio only has a getter
13 | }
14 | window.matchMedia = function(query) {
15 | return {matches: true};
16 | };
17 | return window;
18 | };
19 | });
20 | });
21 | });
22 |
23 | var scope, retinaProvider, $httpBackend, $compile, $timeout;
24 |
25 | beforeEach(module('ngRetina'));
26 | beforeEach(module(function(ngRetinaProvider) {
27 | retinaProvider = ngRetinaProvider;
28 | }));
29 |
30 | beforeEach(inject(function(_$rootScope_, _$httpBackend_, _$compile_, _$timeout_) {
31 | $httpBackend = _$httpBackend_
32 | scope = _$rootScope_.$new();
33 | $compile = _$compile_;
34 | $httpBackend.expect('HEAD', '/image@2x.png').respond(404);
35 | $timeout = _$timeout_;
36 | }));
37 |
38 | afterEach(function() {
39 | window.sessionStorage.removeItem('/image.png');
40 | window.sessionStorage.removeItem('/image@2x.png');
41 | retinaProvider.setLoadErrorHandler(angular.noop);
42 |
43 | $httpBackend.verifyNoOutstandingExpectation();
44 | $httpBackend.verifyNoOutstandingRequest();
45 | });
46 |
47 | it('should be called on 404', function(done) {
48 | retinaProvider.setLoadErrorHandler(function(event, data) {
49 | expect(event.type).toEqual('error');
50 | done();
51 | });
52 |
53 | var element = angular.element('
');
54 | $compile(element)(scope);
55 | scope.$digest();
56 | $httpBackend.flush();
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/Gruntfile.js:
--------------------------------------------------------------------------------
1 | /* jshint node: true */
2 | 'use strict';
3 |
4 | module.exports = function (grunt) {
5 |
6 | // Project configuration.
7 | grunt.initConfig({
8 | // Metadata.
9 | pkg: grunt.file.readJSON('package.json'),
10 | banner: '/*! <%= pkg.name %> - v<%= pkg.version %> - ' +
11 | '<%= grunt.template.today("yyyy-mm-dd") %>\n' +
12 | '<%= pkg.homepage ? "* " + pkg.homepage + "\\n" : "" %>' +
13 | '* Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' +
14 | ' Licensed <%= _.map(pkg.licenses, "type").join(", ") %> */\n',
15 | // Task configuration.
16 | ngmin: {
17 | dist: {
18 | src: ['lib/<%= pkg.name %>.js'],
19 | dest: 'build/<%= pkg.name %>.js'
20 | }
21 | },
22 | concat: {
23 | options: {
24 | banner: '<%= banner %>'
25 | },
26 | dist: {
27 | src: '<%= ngmin.dist.dest %>',
28 | dest: 'build/<%= pkg.name %>.js'
29 | }
30 | },
31 | uglify: {
32 | options: {
33 | banner: '<%= banner %>'
34 | },
35 | dist: {
36 | src: '<%= concat.dist.dest %>',
37 | dest: 'build/<%= pkg.name %>.min.js'
38 | }
39 | },
40 | jshint: {
41 | files: ['Gruntfile.js', 'lib/*.js'],
42 | options: {
43 | curly: false,
44 | browser: true,
45 | eqeqeq: true,
46 | immed: true,
47 | latedef: true,
48 | newcap: true,
49 | noarg: true,
50 | sub: true,
51 | undef: true,
52 | boss: true,
53 | eqnull: true,
54 | expr: true,
55 | node: true,
56 | globals: {
57 | exports: true,
58 | angular: false,
59 | $: false
60 | }
61 | }
62 | }
63 | });
64 |
65 | // These plugins provide necessary tasks.
66 | grunt.loadNpmTasks('grunt-contrib-concat');
67 | grunt.loadNpmTasks('grunt-contrib-uglify');
68 | grunt.loadNpmTasks('grunt-contrib-jshint');
69 | grunt.loadNpmTasks('grunt-ngmin');
70 |
71 | // Build task.
72 | grunt.registerTask('build', ['ngmin', 'concat', 'uglify']);
73 | };
74 |
75 |
--------------------------------------------------------------------------------
/test/unit/ngRetina-fadeInWhenLoaded.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | describe('when fadeInWhenLoaded is set, image should be invisible until loaded', function() {
4 | var scope, retinaProvider, $httpBackend, $compile;
5 | beforeEach(module('ngRetina'));
6 | beforeEach(module(function(ngRetinaProvider) {
7 | retinaProvider = ngRetinaProvider;
8 | }));
9 |
10 | beforeEach(inject(function(_$rootScope_, _$httpBackend_, _$compile_) {
11 | $httpBackend = _$httpBackend_
12 | scope = _$rootScope_.$new();
13 | $compile = _$compile_;
14 | $httpBackend.when('HEAD', '/image@2x.png').respond(200);
15 | $httpBackend.when('GET', '/image@2x.png').respond(200);
16 | retinaProvider.setFadeInWhenLoaded(true);
17 | }));
18 |
19 | afterEach(function() {
20 | window.sessionStorage.clear();
21 | retinaProvider.setFadeInWhenLoaded(false);
22 | $httpBackend.verifyNoOutstandingExpectation();
23 | $httpBackend.verifyNoOutstandingRequest();
24 | });
25 |
26 | it('should set style with transition and opacity', function() {
27 | var element = angular.element('
');
28 | scope.image_url = '/image.png';
29 | $compile(element)(scope);
30 | scope.$digest();
31 | var style = element.attr('style');
32 | expect(style).toMatch(/opacity: 0/);
33 | expect(style).toMatch(/transition/);
34 | expect(style).toMatch(/ease-out/);
35 | expect(style).toMatch(/0\.5s/);
36 | });
37 |
38 | it('should set opacity to 0 when the image has loaded', function() {
39 | var element = angular.element('
');
40 | scope.image_url = '/image.png';
41 | $compile(element)(scope);
42 | scope.$digest();
43 | angular.element(element).triggerHandler('load');
44 | expect(element.attr('style')).toMatch(/opacity: 1/);
45 | });
46 |
47 | it('should not modify opacity if the ngSrc attribute is modified to a value that matches the previous src', function() {
48 | var element = angular.element('
');
49 | scope.model = {
50 | image_url: '/image.png'
51 | }
52 | $compile(element)(scope);
53 | scope.$digest();
54 | element.triggerHandler('load');
55 | expect(element.attr('style')).toMatch(/opacity: 1/);
56 | scope.model = null;
57 | scope.$apply();
58 | expect(element.attr('style')).toMatch(/opacity: 1/);
59 | scope.model = {
60 | image_url: '/image.png'
61 | }
62 | scope.$apply();
63 | expect(element.attr('style')).toMatch(/opacity: 1/);
64 | })
65 | });
66 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "angular-retina",
3 | "description": "Replace AngularJS directive 'ng-src' by a version which supports Retina displays",
4 | "version": "0.5.0",
5 | "files": [
6 | "build/angular-retina.js",
7 | "build/angular-retina.min.js"
8 | ],
9 | "main": "build/angular-retina",
10 | "homepage": "https://github.com/jrief/angular-retina",
11 | "author": {
12 | "name": "Jacob Rief",
13 | "email": "jacob.rief@gmail.com"
14 | },
15 | "repository": {
16 | "type": "git",
17 | "url": "git://github.com/jrief/angular-retina.git"
18 | },
19 | "bugs": {
20 | "url": "https://github.com/jrief/angular-retina/issues"
21 | },
22 | "licenses": [
23 | {
24 | "type": "MIT",
25 | "url": "https://github.com/jrief/angular-retina/blob/master/LICENSE-MIT"
26 | }
27 | ],
28 | "scripts": {
29 | "example": "node node_modules/.bin/http-server",
30 | "jshint": "node_modules/.bin/grunt jshint",
31 | "test": "npm run jshint && node_modules/.bin/karma start --single-run --browsers PhantomJS",
32 | "test-watch": "node_modules/.bin/karma start karma.conf.js --auto-watch",
33 | "test-min": "node_modules/.bin/karma start --single-run --browsers PhantomJS --reporters 'coverage,dots' --min",
34 | "test-all": "npm run jshint && node_modules/.bin/karma start --single-run --browsers 'PhantomJS,Firefox,Chrome'",
35 | "test-travis": "npm run jshint && npm run test-min && node_modules/.bin/karma start --single-run --browsers 'PhantomJS,Firefox' --reporters 'coverage,dots' && npm run coverage-average",
36 | "coverage-average": "node_modules/.bin/coverage-average coverage/text-summary.txt --limit 90",
37 | "precommit": "npm run test-min && npm test && npm run coverage-average",
38 | "build": "grunt build"
39 | },
40 | "devDependencies": {
41 | "grunt": "^1.0.1",
42 | "grunt-contrib-concat": "^1.0.1",
43 | "grunt-contrib-jshint": "^1.1.0",
44 | "grunt-contrib-uglify": "^2.0.0",
45 | "grunt-ngmin": "0.0.3",
46 | "coverage-average": "^1.0.4",
47 | "grunt-bump": "^0.8.0",
48 | "grunt-cli": "^1.2.0",
49 | "http-server": "^0.9.0",
50 | "husky": "^0.12.0",
51 | "jasmine-core": "^2.5.2",
52 | "jscs": "^3.0.7",
53 | "karma": "^1.3.0",
54 | "karma-chrome-launcher": "^2.0.0",
55 | "karma-coverage": "^1.1.1",
56 | "karma-firefox-launcher": "^1.0.0",
57 | "karma-jasmine": "^1.1.0",
58 | "karma-phantomjs-launcher": "^1.0.2",
59 | "phantomjs": "^2.1.7",
60 | "phantomjs-prebuilt": "^2.1.14"
61 | },
62 | "keywords": [
63 | "angularjs",
64 | "ngSrc",
65 | "Retina",
66 | "high resolution image"
67 | ]
68 | }
69 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # angular-retina
2 |
3 | Replaces the AngularJS directive ```ng-src``` by a version which supports Retina displays.
4 |
5 | If the browser runs on a Retina display and the referenced image is available in double
6 | resolution, then load the high resolution version of that image from the server.
7 |
8 | [](https://travis-ci.org/jrief/angular-retina)
9 | [](https://codeclimate.com/github/jrief/angular-retina)
10 |
11 | ## Install
12 | If you prefer to host Javascript files locally instead of using a CDN, install them with:
13 |
14 | ```npm install angular-retina```
15 |
16 | [min]: https://raw.github.com/jrief/angular-retina/master/dist/angular-retina.min.js
17 | [max]: https://raw.github.com/jrief/angular-retina/master/dist/angular-retina.js
18 |
19 | ## Client usage
20 | Into the main HTML code, add the required URLs from the CDN or include the files locally:
21 |
22 | ```html
23 |
24 |
25 | ```
26 | Please note, that *angular-retina* requires ```angularjs-1.2.1``` or later.
27 |
28 | In Javascript, initialize the main module for your AngularJS application:
29 |
30 | ```javascript
31 | var my_app = angular.module('MyApp', [...other dependencies..., 'ngRetina']);
32 | ```
33 |
34 | In the body of any HTML code, access static referenced images using:
35 |
36 | ```html
37 |
38 | ```
39 |
40 | or reference the image using AngularJS's markup:
41 |
42 | ```html
43 |
44 | ```
45 |
46 | Note that when using this module, adding the element attributes ```width="..."```
47 | and/or ```height="..."``` becomes mandatory, as the displayed image
48 | otherwise gets scaled to its double size.
49 |
50 | Just use it in your HTML-code as you would use the common AngularJS directive
51 | [ngSrc](http://docs.angularjs.org/api/ng.directive:ngSrc):
52 |
53 | ## Alternative infix
54 | When this library was written, Apple Inc. recommended to use ```@2x``` as infix, for images
55 | optimized for Retina displays. In late 2013, they changed their mind, and now
56 | [suggest to use the infix](https://developer.apple.com/library/safari/documentation/NetworkingInternet/Conceptual/SafariImageDeliveryBestPractices/ServingImagestoRetinaDisplays/ServingImagestoRetinaDisplays.html) ```_2x```.
57 |
58 | Since Apple's former recommendation, the proposed infix has been hard coded into some server-side
59 | libraries for image generation. Therefore, in version 0.3.0 of *angular-retina*, a configuration function
60 | has been added, which shall be used to set the infix to the newly proposed ```_2x``` – but of course
61 | only, if the server-side also supports it!
62 |
63 | ```javascript
64 | my_app.config(function(ngRetinaProvider) {
65 | ngRetinaProvider.setInfix('_2x');
66 | });
67 | ```
68 |
69 | ## Hide images until loaded, avoiding "broken image" display
70 | To hide (`opacity: 0`) images until the library has determined what resolution to use, set the `src` and the image has finished downloading, use the following config:
71 |
72 | ```javascript
73 | my_app.config(function(ngRetinaProvider) {
74 | ngRetinaProvider.setFadeInWhenLoaded(true);
75 | });
76 | ```
77 |
78 | ## Images with embedded hash
79 |
80 | When using a framework that embeds a digest/hash to the asset URL, the problem
81 | is that a high-resolution verison would have a different hash and would not follow the
82 | usual pattern that ends with @2x. Instead the hash is added at the end, i.e.
83 | `/images/image@2x-{hash2}.jpg`, so the automatic detection of image URL would fail.
84 |
85 | The solution is to supply the high-resolution URL image from the outside of the library
86 | using the `data-at2x` attribute:
87 |
88 | ```html
89 |
90 | ```
91 |
92 | ## On the server
93 | Applications supporting Retina displays should include two separate files for
94 | each image resource. One file provides a standard-resolution version of a given
95 | image, and the second provides a high-resolution version of the same image.
96 | The naming conventions for each pair of image files is as follows:
97 | + Standard: ```.```
98 | + High resolution: ```@2x.```
99 |
100 | If the browser runs on a high-resolution display, and if the referenced image
101 | is available in high-resolution, the corresponding ```
``` tag
102 | is interpreted, such that the image in high-resolution is referenced.
103 |
104 | This module can also be used to reference static image urls, to load the
105 | high resolution version on Retina displays.
106 |
107 | ## Same Origin Policy
108 | In order to verify if the image exists in high resolution, *angular-retina* invokes
109 | a HEAD request with the URL of the high-res image.
110 |
111 | For security reasons, Javascript may not access files on servers starting with a
112 | different domain name. This is known as the
113 | [Same Origin Policy](http://www.w3.org/Security/wiki/Same_Origin_Policy).
114 | Therefore please ensure, that all images accessed through ```ng-src``` can be loaded
115 | from the same domain as the main HTML file.
116 |
117 | ## Release History
118 | + 0.1.0 - initial revision.
119 | + 0.1.3 - fixed problems with minified JS code.
120 | + 0.2.0 - using sessionStorage instead of $cacheFactory to boost performance.
121 | + 0.3.0 - added ```setInfix``` to configure the used infix for Retina images.
122 | + 0.3.1 - added a noretina attribute support to conditionally disable the "retinification" for an element.
123 |
124 | ## License
125 | © 2015 Jacob Rief
126 |
127 | MIT licensed.
128 |
--------------------------------------------------------------------------------
/lib/angular-retina.js:
--------------------------------------------------------------------------------
1 | // Add support for Retina displays when using element attribute "ng-src".
2 | // This module overrides the built-in directive "ng-src" with one which
3 | // distinguishes between standard or high-resolution (Retina) displays.
4 |
5 | (function (
6 | angular,
7 | undefined
8 | ) {
9 | 'use strict';
10 | var infix = '@2x',
11 | dataUrlRegex = /^data:([a-z]+\/[a-z]+(;[a-z\-]+\=[a-z\-]+)?)?(;base64)?,(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/,
12 | allowedImageTypesRegex = /(png|jp[e]?g)$/,
13 | fadeInWhenLoaded = false,
14 | loadErrorHandler = angular.noop;
15 |
16 | var ngRetina = angular.module('ngRetina', [])
17 | .config(['$provide', function ($provide) {
18 | $provide.decorator('ngSrcDirective', ['$delegate', function ($delegate) {
19 | $delegate[0].compile = function (
20 | element,
21 | attrs
22 | ) {
23 | // intentionally empty to override the built-in directive ng-src
24 | };
25 | return $delegate;
26 | }]);
27 | }]);
28 |
29 | // From https://gist.github.com/bgrins/6194623#gistcomment-1671744
30 | function isDataUri(uri) {
31 | return new RegExp(/^\s*data:([a-z]+\/[a-z0-9\-\+]+(;[a-z\-]+\=[a-z0-9\-]+)?)?(;base64)?,[a-z0-9\!\$\&\'\,\(\)\*\+\,\;\=\-\.\_\~\:\@\/\?\%\s]*\s*$/i).test(uri);
32 | }
33 |
34 | ngRetina.provider('ngRetina', function () {
35 | this.setInfix = function setInfix(value) {
36 | infix = value;
37 | };
38 |
39 | this.setFadeInWhenLoaded = function setFadeInWhenLoaded(value) {
40 | fadeInWhenLoaded = value;
41 | };
42 |
43 | this.setLoadErrorHandler = function setLoadErrorHandler(handler) {
44 | loadErrorHandler = handler;
45 | };
46 |
47 | this.$get = angular.noop;
48 | });
49 |
50 | ngRetina.directive('ngSrc', ['$window', '$http', '$log', function (
51 | $window,
52 | $http,
53 | $log
54 | ) {
55 | var msie = parseInt(((/msie (\d+)/.exec($window.navigator.userAgent.toLowerCase()) || [])[1]), 10);
56 | var isRetina = ((function () {
57 | var mediaQuery = '(-webkit-min-device-pixel-ratio: 1.5), (min--moz-device-pixel-ratio: 1.5), ' +
58 | '(-o-min-device-pixel-ratio: 3/2), (min-resolution: 1.5dppx)';
59 | if ($window.devicePixelRatio > 1) {
60 | return true;
61 | }
62 | return $window.matchMedia && $window.matchMedia(mediaQuery).matches;
63 | })());
64 |
65 | function getPathname(url) {
66 | var parser = document.createElement('a');
67 | parser.href = url;
68 | return parser.pathname;
69 | }
70 |
71 | function getHighResolutionURL(url) {
72 | var pathname = getPathname(url);
73 | var parts = pathname.split('.');
74 | if (parts.length < 2) {
75 | return url;
76 | }
77 | parts[parts.length - 2] += infix;
78 | var pathname2x = parts.join('.');
79 | return url.replace(pathname, pathname2x);
80 | }
81 |
82 | return function (
83 | scope,
84 | element,
85 | attrs
86 | ) {
87 | function getSessionStorageItem(imageUrl) {
88 | var item;
89 | try {
90 | item = $window.sessionStorage.getItem(imageUrl);
91 | } catch (e) {
92 | $log.warn('sessionStorage not supported');
93 | item = imageUrl;
94 | }
95 | return item;
96 | }
97 |
98 | function setSessionStorageItem(
99 | imageUrl,
100 | imageUrl2x
101 | ) {
102 | try {
103 | $window.sessionStorage.setItem(imageUrl, imageUrl2x);
104 | } catch (e) {
105 | $log.warn('sessionStorage not supported');
106 | }
107 | }
108 |
109 | function get2xImageURL(imageUrl) {
110 | return attrs.at2x || getSessionStorageItem(imageUrl);
111 | }
112 |
113 | function isCurrImgSrc(imgSrc) {
114 | var currImgSrc = attrs.ngSrc;
115 | var currImgSrc2x = get2xImageURL(currImgSrc);
116 | return currImgSrc === imgSrc || currImgSrc2x === imgSrc;
117 | }
118 |
119 | function setImgSrc(imageUrl) {
120 | if (!isCurrImgSrc(imageUrl)) return;
121 |
122 | element.on('error', loadErrorHandler);
123 |
124 | attrs.$set('src', imageUrl);
125 | if (msie) {
126 | element.prop('src', imageUrl);
127 | }
128 | }
129 |
130 | function set2xVariant(imageUrl) {
131 | var imageUrl2x = get2xImageURL(imageUrl);
132 |
133 | if (!imageUrl2x) {
134 | imageUrl2x = getHighResolutionURL(imageUrl);
135 | var request = {
136 | method: imageUrl2x.indexOf('?') < 0 ? 'HEAD' : 'GET',
137 | url: imageUrl2x
138 | };
139 | $http(request)
140 | .then(function (
141 | data,
142 | status
143 | ) {
144 | setSessionStorageItem(imageUrl, imageUrl2x);
145 | setImgSrc(imageUrl2x);
146 | })
147 | .catch(function (
148 | data,
149 | status,
150 | headers,
151 | config
152 | ) {
153 | setSessionStorageItem(imageUrl, imageUrl);
154 | setImgSrc(imageUrl);
155 | });
156 | } else {
157 | setImgSrc(imageUrl2x);
158 | }
159 | }
160 |
161 | attrs.$observe('ngSrc', function (
162 | imageUrl,
163 | oldValue
164 | ) {
165 | if (!imageUrl) {
166 | return;
167 | }
168 |
169 | if (isDataUri(imageUrl)) {
170 | return setImgSrc(imageUrl);
171 | }
172 |
173 | if (fadeInWhenLoaded && !getSessionStorageItem('fadedIn-' + imageUrl)) {
174 | element.css({
175 | opacity: 0,
176 | '-o-transition': 'opacity 0.5s ease-out',
177 | '-moz-transition': 'opacity 0.5s ease-out',
178 | '-webkit-transition': 'opacity 0.5s ease-out',
179 | 'transition': 'opacity 0.5s ease-out'
180 | });
181 | element.on('load', function () {
182 | setSessionStorageItem('fadedIn-' + imageUrl, true);
183 | element.css('opacity', 1);
184 | });
185 | }
186 |
187 | if (isRetina &&
188 | angular.isUndefined(attrs.noretina) &&
189 | element[0].tagName === 'IMG' &&
190 | getPathname(imageUrl).match(allowedImageTypesRegex) && !imageUrl.match(dataUrlRegex)) {
191 | set2xVariant(imageUrl);
192 | } else {
193 | setImgSrc(imageUrl);
194 | }
195 | });
196 | };
197 | }]);
198 | })(window.angular);
199 |
--------------------------------------------------------------------------------
/test/unit/ngRetina.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | describe('test module angular-retina', function() {
4 | var $window;
5 |
6 | describe('on high resolution displays', function() {
7 | var $httpBackend, scope, retinaProvider;
8 |
9 | beforeEach(function() {
10 | module(function($provide) {
11 | $provide.provider('$window', function() {
12 | this.$get = function() {
13 | try {
14 | window.devicePixelRatio = 2;
15 | } catch (TypeError) {
16 | // in Firefox window.devicePixelRatio only has a getter
17 | }
18 | window.matchMedia = function(query) {
19 | return {matches: true};
20 | };
21 | return window;
22 | };
23 | });
24 | });
25 | });
26 |
27 | beforeEach(module('ngRetina'));
28 | beforeEach(module(function(ngRetinaProvider) {
29 | retinaProvider = ngRetinaProvider;
30 | }));
31 |
32 | beforeEach(inject(function($injector, $rootScope) {
33 | scope = $rootScope.$new();
34 | $httpBackend = $injector.get('$httpBackend');
35 | }));
36 |
37 | afterEach(function() {
38 | window.sessionStorage.removeItem('/image.png');
39 | window.sessionStorage.removeItem('/image@2x.png');
40 | window.sessionStorage.removeItem('/picture.png');
41 | window.sessionStorage.removeItem('/picture@2x.png');
42 | $httpBackend.verifyNoOutstandingExpectation();
43 | $httpBackend.verifyNoOutstandingRequest();
44 | });
45 |
46 | describe('for static "ng-src" tags', function() {
47 | it('should set src tag with a highres image', inject(function($compile) {
48 | var element = angular.element('
');
49 | $httpBackend.when('HEAD', '/image@2x.png').respond(200);
50 | $compile(element)(scope);
51 | scope.$digest();
52 | $httpBackend.flush();
53 | expect(element.attr('src')).toBe('/image@2x.png');
54 | }));
55 |
56 | it('should set src directly if ng-src is a data-uri', inject(function($compile) {
57 | var element = angular.element('
');
58 | $compile(element)(scope);
59 | scope.$apply();
60 | expect(element.attr('src')).toBe('data:image/png;base64,iVBOD');
61 | }));
62 | });
63 |
64 | describe('for marked up "ng-src" tags', function() {
65 | var element;
66 |
67 | beforeEach(inject(function($compile) {
68 | element = angular.element('
');
69 | scope.imageUrl = '/image.png';
70 | $httpBackend.when('HEAD', '/image@2x.png').respond(200);
71 | $compile(element)(scope);
72 | scope.$digest();
73 | $httpBackend.flush();
74 | }));
75 |
76 | it('should copy content from "ng-src" to "src" tag', function() {
77 | expect(element.attr('src')).toBe('/image@2x.png');
78 | });
79 |
80 | describe('should observe scope.imageUrl', function() {
81 | beforeEach(function() {
82 | $httpBackend.when('HEAD', '/picture@2x.png').respond(200);
83 | scope.imageUrl = '/picture.png';
84 | scope.$digest();
85 | $httpBackend.flush();
86 | });
87 |
88 | it('and replace src tag with another picture', function() {
89 | expect(element.attr('src')).toBe('/picture@2x.png');
90 | });
91 |
92 | it('and check if the client side cache is working', function() {
93 | scope.imageUrl = '/image.png';
94 | scope.$digest();
95 | expect(element.attr('src')).toBe('/image@2x.png');
96 | });
97 |
98 | it('and should modify jpg', function() {
99 | $httpBackend.when('HEAD', '/image@2x.png').respond(200);
100 | $httpBackend.when('HEAD', '/image@2x.jpg').respond(200);
101 | $httpBackend.when('HEAD', '/image@2x.jpeg').respond(200);
102 | $httpBackend.when('HEAD', '/image@2x.gif').respond(200);
103 | $httpBackend.when('HEAD', '/image@2x.svg').respond(200);
104 |
105 | scope.imageUrl = '/image.png';
106 | scope.$digest();
107 | expect(element.attr('src')).toBe('/image@2x.png');
108 |
109 | scope.imageUrl = '/image.jpg';
110 | scope.$digest();
111 | $httpBackend.flush();
112 | expect(element.attr('src')).toBe('/image@2x.jpg');
113 |
114 | scope.imageUrl = '/image.jpeg';
115 | scope.$digest();
116 | $httpBackend.flush();
117 | expect(element.attr('src')).toBe('/image@2x.jpeg');
118 |
119 | scope.imageUrl = '/image.gif';
120 | scope.$digest();
121 | expect(element.attr('src')).toBe('/image.gif');
122 |
123 | scope.imageUrl = '/image.svg';
124 | scope.$digest();
125 | expect(element.attr('src')).toBe('/image.svg');
126 | })
127 | });
128 | });
129 |
130 | describe('for "ng-src" tags containing base64 encode URLs', function() {
131 | it('should not invoke any HEAD request', inject(function($compile) {
132 | var base64img = 'data:image/png;base64,' +
133 | 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAABGdBTUEAALGP' +
134 | 'C/xhBQAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9YGARc5KB0XV+IA' +
135 | 'AAAddEVYdENvbW1lbnQAQ3JlYXRlZCB3aXRoIFRoZSBHSU1Q72QlbgAAAF1J' +
136 | 'REFUGNO9zL0NglAAxPEfdLTs4BZM4DIO4C7OwQg2JoQ9LE1exdlYvBBeZ7jq' +
137 | 'ch9//q1uH4TLzw4d6+ErXMMcXuHWxId3KOETnnXXV6MJpcq2MLaI97CER3N0' +
138 | 'vr4MkhoXe0rZigAAAABJRU5ErkJggg==';
139 | var element = angular.element('
');
140 | $compile(element)(scope);
141 | scope.$digest();
142 | expect(element.attr('src')).toBe(base64img);
143 | }));
144 | });
145 |
146 | describe('with alternative infix', function() {
147 | beforeEach(function() {
148 | retinaProvider.setInfix('_2x');
149 | });
150 |
151 | it('should set src tag with an alternative highres image', inject(function($compile) {
152 | var element = angular.element('
');
153 | $httpBackend.when('HEAD', '/image_2x.png').respond(200);
154 | $compile(element)(scope);
155 | scope.$digest();
156 | $httpBackend.flush();
157 | expect(element.attr('src')).toBe('/image_2x.png');
158 | }));
159 |
160 | afterEach(function() {
161 | retinaProvider.setInfix('@2x');
162 | });
163 | });
164 |
165 | describe('with the alternate high-resolution image is provided', function () {
166 | it('should set src tag with the alternate highres image', inject(function($compile) {
167 | var element = angular.element('
');
168 | $httpBackend.when('HEAD', '/image-with-hash@2x.png').respond(200);
169 | $compile(element)(scope);
170 | scope.$digest();
171 | expect(element.attr('src')).toBe('/image-with-hash@2x.png');
172 | }));
173 | });
174 |
175 | describe('with a query in the URL', function() {
176 | it('should apply the infix and preserve the query', inject(function($compile) {
177 | var element = angular.element('
');
178 | $httpBackend.whenGET('/image@2x.jpg?query=foo').respond(200, '{}');
179 | $compile(element)(scope);
180 | scope.$digest();
181 | $httpBackend.flush();
182 | expect(element.attr('src')).toBe('/image@2x.jpg?query=foo');
183 | }));
184 | });
185 |
186 | describe('if the high resolution image is not available', function() {
187 | beforeEach(function() {
188 | $httpBackend.when('HEAD', '/image@2x.png').respond(404);
189 | });
190 |
191 | it('should copy content from "ng-src" to "src" tag', inject(function($compile) {
192 | var element = angular.element('
');
193 | $compile(element)(scope);
194 | scope.$digest();
195 | $httpBackend.flush();
196 | expect(element.attr('src')).toBe('/image.png');
197 | }));
198 |
199 | it('should copy content from scope object to "src" tag', inject(function($compile) {
200 | var element = angular.element('
');
201 | scope.imageUrl = '/image.png';
202 | $compile(element)(scope);
203 | scope.$digest();
204 | $httpBackend.flush();
205 | expect(element.attr('src')).toBe('/image.png');
206 | }));
207 | });
208 | });
209 |
210 | describe('on standard resolution displays using images in their low resolution version', function() {
211 | var scope;
212 |
213 | beforeEach(function() {
214 | module(function($provide) {
215 | $provide.provider('$window', function() {
216 | this.$get = function() {
217 | try {
218 | window.devicePixelRatio = 1;
219 | } catch (TypeError) {
220 | // in Firefox window.devicePixelRatio only has a getter
221 | }
222 | window.matchMedia = function(query) {
223 | return {matches: false};
224 | };
225 | return window;
226 | };
227 | });
228 | });
229 | module('ngRetina');
230 | });
231 |
232 | beforeEach(inject(function($rootScope) {
233 | scope = $rootScope.$new();
234 | }));
235 |
236 | it('should copy content from "ng-src" to "src" tag', inject(function($compile) {
237 | var element = angular.element('
');
238 | $compile(element)(scope);
239 | scope.$digest();
240 | expect(element.attr('src')).toBe('/image.png');
241 | }));
242 |
243 | it('should copy content from scope object to "src" tag', inject(function($compile) {
244 | var element = angular.element('
');
245 | scope.imageUrl = '/image.png';
246 | $compile(element)(scope);
247 | scope.$digest();
248 | expect(element.attr('src')).toBe('/image.png');
249 | }));
250 | });
251 | });
252 |
--------------------------------------------------------------------------------