├── .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 | [![Build Status](https://travis-ci.org/jrief/angular-retina.png)](https://travis-ci.org/jrief/angular-retina) 9 | [![Code Climate](https://codeclimate.com/github/jrief/angular-retina/badges/gpa.svg)](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 | --------------------------------------------------------------------------------