├── .editorconfig ├── .eslintrc.json ├── .github ├── dependabot.yaml └── workflows │ └── ci.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── angular.json ├── karma.conf.js ├── ng-package.json ├── package-lock.json ├── package.json ├── src ├── lib │ ├── ngx-image-zoom.component.css │ ├── ngx-image-zoom.component.html │ ├── ngx-image-zoom.component.spec.ts │ ├── ngx-image-zoom.component.ts │ ├── ngx-image-zoom.module.ts │ ├── ngx-image-zoom.service.spec.ts │ ├── ngx-image-zoom.service.ts │ └── zoom-modes │ │ ├── click-zoom-mode.spec.ts │ │ ├── click-zoom-mode.ts │ │ ├── hover-freeze-zoom-mode.spec.ts │ │ ├── hover-freeze-zoom-mode.ts │ │ ├── hover-zoom-mode.spec.ts │ │ ├── hover-zoom-mode.ts │ │ ├── toggle-click-zoom-mode.spec.ts │ │ ├── toggle-click-zoom-mode.ts │ │ ├── toggle-freeze-zoom-mode.spec.ts │ │ ├── toggle-freeze-zoom-mode.ts │ │ ├── toggle-zoom-mode.spec.ts │ │ ├── toggle-zoom-mode.ts │ │ └── zoom-mode.ts ├── public-api.ts └── test.ts ├── tsconfig.json ├── tsconfig.lib.json ├── tsconfig.lib.prod.json └── tsconfig.spec.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 120 11 | 12 | [*.md] 13 | max_line_length = off 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["projects/**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:@angular-eslint/recommended", 11 | // This is required if you use inline templates in Components 12 | "plugin:@angular-eslint/template/process-inline-templates", 13 | // To play nice with prettier 14 | "prettier" 15 | ], 16 | "rules": { 17 | /** 18 | * Any TypeScript source code (NOT TEMPLATE) related rules you wish to use/reconfigure over and above the 19 | * recommended set provided by the @angular-eslint project would go here. 20 | */ 21 | "@angular-eslint/component-selector": [ 22 | "error", 23 | { 24 | "type": "element", 25 | "prefix": "lib", 26 | "style": "kebab-case" 27 | } 28 | ], 29 | "@angular-eslint/directive-selector": [ 30 | "error", 31 | { 32 | "type": "attribute", 33 | "prefix": "app", 34 | "style": "camelCase" 35 | } 36 | ], 37 | "@angular-eslint/no-input-rename": ["off"] 38 | } 39 | }, 40 | { 41 | "files": ["*.html"], 42 | "extends": ["plugin:@angular-eslint/template/recommended"], 43 | "rules": { 44 | /** 45 | * Any template/HTML related rules you wish to use/reconfigure over and above the 46 | * recommended set provided by the @angular-eslint project would go here. 47 | */ 48 | } 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | allow: 8 | - dependency-type: production 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request CI 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - synchronize 8 | push: 9 | branches: 10 | - master 11 | 12 | jobs: 13 | build: 14 | name: Pull Request build test 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v3 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 16 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Run eslint 30 | run: npm run lint 31 | 32 | - name: Run prettier 33 | run: npm run prettier:check 34 | 35 | - name: Build 36 | run: npm run build 37 | 38 | - name: Run tests 39 | run: npm run test:ci 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /demosite 8 | 9 | # dependencies 10 | /node_modules 11 | 12 | # profiling files 13 | chrome-profiler-events*.json 14 | speed-measure-plugin*.json 15 | 16 | # IDEs and editors 17 | /.idea 18 | 19 | # misc 20 | /.angular/cache 21 | /.sass-cache 22 | /connect.lock 23 | /coverage 24 | /libpeerconnection.log 25 | npm-debug.log 26 | yarn-error.log 27 | testem.log 28 | /typings 29 | 30 | # System Files 31 | .DS_Store 32 | Thumbs.db 33 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .angular/* 2 | demosite/* 3 | dist/* 4 | node_modules/* 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 2.1.0 4 | 5 | ### Features 6 | 7 | - Added SafeUrl as data type for thumbImage and fullImage. 8 | 9 | 10 | 11 | # 2.0.0 12 | 13 | ### Breaking changes 14 | 15 | - 'hover-freeze' has changed name to 'toggle-freeze', a more descriptive name. And a new mode has been added called 'hover-freeze', see README for description. 16 | - Demonstration of available features available in ([v2.0.0](https://wittlock.github.io/ngx-image-zoom/)) 17 | 18 | 19 | 20 | # 1.0.1 21 | 22 | ### Features 23 | 24 | - Added `alt` and `title` attributes to the thumb and full image with `altText` and `titleText` inputs. 25 | 26 | 27 | 28 | # 1.0.0 29 | 30 | ### Features 31 | 32 | - Updated for latest Angular 33 | - Merged PR#88 for toggle-click zoom mode 34 | - Added additional @Output, imagesLoaded, showing the value of isReady. Which is true when both thumbnail and fullImage is loaded 35 | 36 | 37 | 38 | # 0.6.0 39 | 40 | ### Breaking changes 41 | 42 | - Removed input parameters _scrollParentSelector_ and _isInsideStaticContainer_ as I believe these are no longer needed. 43 | 44 | ### Features 45 | 46 | - Rewrote the zooming position calculations, it feels much more robust now and will hopefully perform as expected in 47 | more situations with complex layouts. 48 | 49 | 50 | 51 | # 0.5.1 52 | 53 | ### Bugfixes 54 | 55 | - Replaced BrowserModule with CommonModule. ([Angular guide](https://angular.io/guide/frequent-ngmodules#browsermodule-and-commonmodule)) 56 | 57 | 58 | 59 | # 0.5.0 60 | 61 | ### Breaking changes 62 | 63 | - To comply with recommendaing naming conventions the follow name changes have been done: 64 | - Changed tag name from _ngx-image-zoom_ to _lib-ngx-image-zoom_ ([style 02-07](https://angular.io/guide/styleguide#style-02-07)) 65 | - Changed output _onZoomScroll_ to _zoomScroll_ ([style 05-16](https://angular.io/guide/styleguide#style-05-16)) 66 | - Changed output _onZoomPosition_ to _zoomPosition_ ([style 05-16](https://angular.io/guide/styleguide#style-05-16)) 67 | - With the upgrade _.forRoot()_ is no longer needed when importing the NgxImageZoomModule in your project. 68 | 69 | ### Features 70 | 71 | - Completely redid the library wrapping for Angular 9 support. 72 | - Library is now in Angular Package Format for better compatibility. 73 | - Added a new input scrollParentSelector to further control zooming in complex layouts. 74 | 75 | ### Bugfixes 76 | 77 | - Clean up event listeners when component is destroyed. 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Mathias Wittlock 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ngx-image-zoom 2 | 3 | [![npm version](http://img.shields.io/npm/v/ngx-image-zoom.svg)](https://npmjs.org/package/ngx-image-zoom) 4 | 5 | ## Project status 6 | 7 | This version is only tested with Angular15. Moving forward this library may or may not work on older versions 8 | of Angular anymore. 9 | 10 | Still in early development, more features are planned and incoming. Should be in a working 11 | state right now but it's not tested in lots of different setups yet. 12 | 13 | **Breaking changes** in version 0.5.0, see [changelog](CHANGELOG.md) for details. 14 | 15 | Demonstration of available features available [here](https://wittlock.github.io/ngx-image-zoom/). 16 | 17 | **Breaking changes** in version 2.0.0, see [changelog](CHANGELOG.md) for details. 18 | 19 | Demonstration of available features available ([here](https://wittlock.github.io/ngx-image-zoom/)). 20 | 21 | ## About 22 | 23 | NgxImageZoom is inspired by [angular2-image-zoom](https://github.com/brtnshrdr/angular2-image-zoom) and 24 | JQuery libraries such as [jQuery Zoom](http://www.jacklmoore.com/zoom/) and 25 | [elevateZoom-plus](http://igorlino.github.io/elevatezoom-plus/) but a pure Angular2+ implementation of 26 | similar concepts. This plugin works with both URLs to images and in-line images 27 | ([Data URI](https://en.wikipedia.org/wiki/Data_URI_scheme)). 28 | 29 | ## Available options 30 | 31 | All settings except _thumbImage_ are optional. If no _fullImage_ is provided the thumbImage will be 32 | used as the high resolution version as well. 33 | 34 | | Option | Default value | Description | 35 | | :--------------: | :----------------: | ---------------------------------------------------------------------------------------------------------------------------------- | 36 | | thumbImage | _none_ | (Required) The smaller version of the image that will be shown when there's no interaction by the user. String or SafeUrl type. | 37 | | fullImage | _none_ | The full resolution version of the image to be used when zooming. If not supplied thumbImage will be used. String or SafeUrl type. | 38 | | magnification | 1 | The zoom factor to be used by default. 1 means we use the fullImage at its actual resolution. | 39 | | zoomMode | 'hover' | The mode of zooming to use, these are explained in a table below. | 40 | | enableScrollZoom | false | Boolean that toggles if the mouse wheel should be captured when hovering over the image to adjust magnification. | 41 | | scrollStepSize | 0.1 | When using scroll zoom this setting determines how big steps each scroll changes the zoom. | 42 | | enableLens | false | If enabled only a small portion around the mouse cursor will actually magnify instead of the entire image area. | 43 | | lensWidth | 100 | Width of the lens, if enabled. | 44 | | lensHeight | 100 | Height of the lens, if enabled. | 45 | | circularLens | false | Make the lens circular instead of square. This will only look good if width and height are equal. | 46 | | minZoomRatio | _baseRatio_ | Lower limit on how much zoom can be applied with scrollZoom enabled. See below for details. | 47 | | maxZoomRatio | 2 | Upper limit on how much zoom can be applied with scrollZoom enabled. See below for details. | 48 | | altText | '' | `alt` attribute of the thumb and full image. | 49 | | titleText | '' | `title` attribute of the thumb and full image. | 50 | 51 | ### Zoom modes 52 | 53 | | Mode | Description | 54 | | :-----------: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 55 | | hover | Whenever the mouse cursor moves over the thumbnail it will show the zoomed image until it leaves the thumbnail. | 56 | | click | Similar to hover but it only starts zooming if the user clicks the image. Moving the cursor away from the image disables it again. | 57 | | toggle | A click in the image will zoom at the point of the cursor. Another click will restore the small image. | 58 | | toggle-click | Combination of toggle and click. A click in the image will start zooming. Another click or moving the cursor away from the image will restore the small image. | 59 | | toggle-freeze | First click enables hover mode, second click freezes the zoomed image where it is, third click restores thumbnail. | 60 | | hover-freeze | Whenever the mouse cursor moves over the thumbnail it will show the zoomed image, first click freezes the zoomed image where it is, second click restores thumbnail. | 61 | 62 | ### Zoom ratio 63 | 64 | The zoom ratio used in the _minZoomRatio_ and _maxZoomRatio_ settings refer to the relative size of the thumbnail 65 | and the full size image. The _baseRatio_ default value is the calculated ratio that would make the zoomed image equal 66 | in size to the thumbnail. For example, if the full size image is 10x larger than the thumbnail, then _minZoomRatio_ will 67 | default to _0.1_, as in the full size image can at its smallest be shown at 0.1 times its original size. The default 68 | value for _maxZoomRatio_ being _1_ means the largest the fullSize image can appear is twice its original size. 69 | 70 | ## Available output 71 | 72 | The component outputs the follow events that can be triggered on. 73 | 74 | | Event name | Description | 75 | | :-------------: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 76 | | zoomScroll | Whenever the user changes the zoom level using the scroll wheel this event will fire with the current zoom ratio (see above). | 77 | | zoomPosition | When the point on where the zoom is focused changes this event emits a Coord event (interface exported from the module) with X/Y in pixels relative thumbnails top left corner. Practically whenever the user moves the mouse cursor over the image. | 78 | | imagesLoaded | When the thumbnail and fullImage are loaded and ready to be used this output emits "true". It will emit "false" if images are changed and on initial setup | 79 | 80 | ## Installation 81 | 82 | To install this library, run: 83 | 84 | ```bash 85 | $ npm install ngx-image-zoom --save 86 | ``` 87 | 88 | ## Using this library 89 | 90 | From your Angular `AppModule`: 91 | 92 | ```typescript 93 | import { BrowserModule } from '@angular/platform-browser'; 94 | import { NgModule } from '@angular/core'; 95 | 96 | import { AppComponent } from './app.component'; 97 | 98 | // Import the library 99 | import { NgxImageZoomModule } from 'ngx-image-zoom'; 100 | 101 | @NgModule({ 102 | declarations: [AppComponent], 103 | imports: [ 104 | BrowserModule, 105 | NgxImageZoomModule, // <-- Add this line 106 | ], 107 | providers: [], 108 | bootstrap: [AppComponent], 109 | }) 110 | export class AppModule {} 111 | ``` 112 | 113 | Once the library is imported, you can use its component in your Angular application: 114 | 115 | ```xml 116 | 117 |

118 | {{title}} 119 |

120 | 124 | ``` 125 | 126 | ## License 127 | 128 | MIT © Mathias Wittlock 129 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "./", 5 | "projects": { 6 | "ngx-image-zoom": { 7 | "projectType": "library", 8 | "root": "./", 9 | "sourceRoot": "src", 10 | "prefix": "lib", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "tsConfig": "tsconfig.lib.json", 16 | "project": "ng-package.json" 17 | }, 18 | "configurations": { 19 | "production": { 20 | "tsConfig": "tsconfig.lib.prod.json" 21 | } 22 | } 23 | }, 24 | "test": { 25 | "builder": "@angular-devkit/build-angular:karma", 26 | "options": { 27 | "main": "src/test.ts", 28 | "tsConfig": "tsconfig.spec.json", 29 | "karmaConfig": "karma.conf.js" 30 | } 31 | }, 32 | "lint": { 33 | "builder": "@angular-eslint/builder:lint", 34 | "options": { 35 | "lintFilePatterns": [".//**/*.ts", ".//**/*.html"] 36 | } 37 | } 38 | } 39 | } 40 | }, 41 | "cli": { 42 | "schematicCollections": ["@angular-eslint/schematics"] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma'), 14 | ], 15 | client: { 16 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/ngx-image-zoom'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true, 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true, 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "./dist", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-image-zoom", 3 | "version": "3.0.0", 4 | "scripts": { 5 | "build": "ng build ngx-image-zoom", 6 | "build:release": "npm run lint && npm run prettier:check && npm run test:ci && ng build ngx-image-zoom --configuration production", 7 | "build:watch": "ng build ngx-image-zoom --watch", 8 | "ng": "ng", 9 | "lint": "ng lint", 10 | "prettier:check": "npx prettier --check .", 11 | "prettier:fix": "npx prettier --write .", 12 | "release": "npm run build:release && npm publish dist/", 13 | "release:beta": "npm run build:release && npm publish dist/ --tag beta", 14 | "start": "ng serve", 15 | "test": "ng test", 16 | "test:ci": "ng test --browsers=ChromeHeadless --watch=false" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "https://www.github.com/wittlock/ngx-image-zoom" 21 | }, 22 | "author": { 23 | "name": "Mathias Wittlock", 24 | "email": "1336118+wittlock@users.noreply.github.com" 25 | }, 26 | "keywords": [ 27 | "angular", 28 | "angular component", 29 | "image zoom" 30 | ], 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://www.github.com/wittlock/ngx-image-zoom/issues" 34 | }, 35 | "dependencies": { 36 | "tslib": "^2.5.3" 37 | }, 38 | "devDependencies": { 39 | "@angular-devkit/build-angular": "^15.1.4", 40 | "@angular-eslint/builder": "15.2.1", 41 | "@angular-eslint/eslint-plugin": "15.2.1", 42 | "@angular-eslint/eslint-plugin-template": "15.2.1", 43 | "@angular-eslint/schematics": "15.2.1", 44 | "@angular-eslint/template-parser": "15.2.1", 45 | "@angular/animations": "^15.1.3", 46 | "@angular/cli": "^15.1.4", 47 | "@angular/common": "^15.1.3", 48 | "@angular/compiler": "^15.1.3", 49 | "@angular/compiler-cli": "^15.1.3", 50 | "@angular/core": "^15.1.3", 51 | "@angular/forms": "^15.1.3", 52 | "@angular/language-service": "^15.1.3", 53 | "@angular/platform-browser": "^15.1.3", 54 | "@angular/platform-browser-dynamic": "^15.1.3", 55 | "@angular/router": "^15.1.3", 56 | "@types/jasmine": "~4.3.1", 57 | "@types/jasminewd2": "~2.0.10", 58 | "@types/node": "^18.13.0", 59 | "@typescript-eslint/eslint-plugin": "5.48.2", 60 | "@typescript-eslint/parser": "5.48.2", 61 | "codelyzer": "^6.0.2", 62 | "eslint": "^8.33.0", 63 | "eslint-config-prettier": "^8.8.0", 64 | "jasmine-core": "~4.5.0", 65 | "jasmine-spec-reporter": "~7.0.0", 66 | "karma": "~6.4.1", 67 | "karma-chrome-launcher": "~3.1.1", 68 | "karma-coverage-istanbul-reporter": "~3.0.3", 69 | "karma-jasmine": "~5.1.0", 70 | "karma-jasmine-html-reporter": "^2.0.0", 71 | "ng-packagr": "^15.1.1", 72 | "prettier": "2.8.8", 73 | "protractor": "~7.0.0", 74 | "rxjs": "~7.8.0", 75 | "ts-node": "~10.9.1", 76 | "typescript": "~4.9.5", 77 | "zone.js": "~0.12.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/ngx-image-zoom.component.css: -------------------------------------------------------------------------------- 1 | .ngxImageZoomContainer { 2 | position: relative; 3 | margin: auto; 4 | overflow: hidden; 5 | pointer-events: none; 6 | } 7 | 8 | .ngxImageZoomThumbnail { 9 | pointer-events: all; 10 | } 11 | 12 | .ngxImageZoomFull { 13 | position: absolute; 14 | max-width: none; 15 | max-height: none; 16 | display: none; 17 | pointer-events: none; 18 | } 19 | 20 | .ngxImageZoomFullContainer { 21 | position: absolute; 22 | overflow: hidden; 23 | pointer-events: none; 24 | } 25 | 26 | .ngxImageZoomFullContainer.ngxImageZoomLensEnabled { 27 | border: 2px solid red; 28 | cursor: crosshair; 29 | pointer-events: none; 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/ngx-image-zoom.component.html: -------------------------------------------------------------------------------- 1 |
7 | 15 | 16 |
28 | 41 |
42 |
43 | -------------------------------------------------------------------------------- /src/lib/ngx-image-zoom.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { NgxImageZoomComponent } from './ngx-image-zoom.component'; 3 | 4 | describe('NgxImageZoomComponent', () => { 5 | let component: NgxImageZoomComponent; 6 | let fixture: ComponentFixture; 7 | 8 | beforeEach(async(() => { 9 | TestBed.configureTestingModule({ 10 | declarations: [NgxImageZoomComponent], 11 | }).compileComponents(); 12 | })); 13 | 14 | beforeEach(() => { 15 | fixture = TestBed.createComponent(NgxImageZoomComponent); 16 | component = fixture.componentInstance; 17 | fixture.detectChanges(); 18 | }); 19 | 20 | it('should create', () => { 21 | expect(component).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/lib/ngx-image-zoom.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ElementRef, 4 | EventEmitter, 5 | Input, 6 | OnChanges, 7 | OnDestroy, 8 | OnInit, 9 | Output, 10 | Renderer2, 11 | ViewChild, 12 | } from '@angular/core'; 13 | import { SafeUrl } from '@angular/platform-browser'; 14 | import { Subscription } from 'rxjs'; 15 | import { NgxImageZoomService } from './ngx-image-zoom.service'; 16 | import { ClickZoomMode } from './zoom-modes/click-zoom-mode'; 17 | import { HoverFreezeZoomMode } from './zoom-modes/hover-freeze-zoom-mode'; 18 | import { HoverZoomMode } from './zoom-modes/hover-zoom-mode'; 19 | import { ToggleClickZoomMode } from './zoom-modes/toggle-click-zoom-mode'; 20 | import { ToggleFreezeZoomMode } from './zoom-modes/toggle-freeze-zoom-mode'; 21 | import { ToggleZoomMode } from './zoom-modes/toggle-zoom-mode'; 22 | import { ZoomMode } from './zoom-modes/zoom-mode'; 23 | 24 | export interface Coord { 25 | x: number; 26 | y: number; 27 | } 28 | 29 | @Component({ 30 | selector: 'lib-ngx-image-zoom', 31 | templateUrl: './ngx-image-zoom.component.html', 32 | styleUrls: ['./ngx-image-zoom.component.css'], 33 | providers: [NgxImageZoomService], 34 | }) 35 | export class NgxImageZoomComponent implements OnInit, OnChanges, OnDestroy { 36 | private static readonly validZoomModes: string[] = [ 37 | 'hover', 38 | 'toggle', 39 | 'click', 40 | 'toggle-click', 41 | 'toggle-freeze', 42 | 'hover-freeze', 43 | ]; 44 | 45 | @ViewChild('zoomContainer', { static: true }) zoomContainer!: ElementRef; 46 | @ViewChild('imageThumbnail', { static: true }) imageThumbnail!: ElementRef; 47 | @ViewChild('fullSizeImage', { static: true }) fullSizeImage!: ElementRef; 48 | 49 | @Output() zoomScroll = new EventEmitter(); 50 | @Output() zoomPosition = new EventEmitter(); 51 | @Output() imagesLoaded = new EventEmitter(); 52 | 53 | public thumbImage?: string | SafeUrl | null; 54 | public fullImage?: string | SafeUrl | null; 55 | public lensBorderRadius = 0; 56 | 57 | private zoomMode = 'hover'; 58 | private enableScrollZoom = false; 59 | private scrollStepSize = 0.1; 60 | private circularLens = false; 61 | 62 | private thumbImageLoaded = false; 63 | 64 | private zoomInstance: ZoomMode | undefined; 65 | private subscriptions: Array = []; 66 | private eventListeners: (() => void)[] = []; 67 | 68 | private zoomModesMap = new Map ZoomMode>([ 69 | ['click', ClickZoomMode], 70 | ['hover-freeze', HoverFreezeZoomMode], 71 | ['hover', HoverZoomMode], 72 | ['toggle-click', ToggleClickZoomMode], 73 | ['toggle-freeze', ToggleFreezeZoomMode], 74 | ['toggle', ToggleZoomMode], 75 | ]); 76 | 77 | constructor(public zoomService: NgxImageZoomService, private renderer: Renderer2) {} 78 | 79 | @Input('thumbImage') 80 | public set setThumbImage(thumbImage: string | SafeUrl | null) { 81 | this.thumbImageLoaded = false; 82 | this.setIsReady(false); 83 | this.thumbImage = thumbImage; 84 | } 85 | 86 | @Input('fullImage') 87 | public set setFullImage(fullImage: string | SafeUrl | null) { 88 | this.zoomService.fullImageLoaded = false; 89 | this.setIsReady(false); 90 | this.fullImage = fullImage; 91 | } 92 | 93 | @Input('zoomMode') 94 | public set setZoomMode(zoomMode: string) { 95 | if (NgxImageZoomComponent.validZoomModes.some((m) => m === zoomMode)) { 96 | this.zoomMode = zoomMode; 97 | } 98 | } 99 | 100 | @Input('magnification') 101 | public set setMagnification(magnification: number) { 102 | this.zoomService.magnification = Number(magnification) || this.zoomService.magnification; 103 | this.zoomScroll.emit(this.zoomService.magnification); 104 | } 105 | 106 | @Input('minZoomRatio') 107 | public set setMinZoomRatio(minZoomRatio: number) { 108 | const ratio = Number(minZoomRatio) || this.zoomService.minZoomRatio || this.zoomService.baseRatio || 0; 109 | this.zoomService.minZoomRatio = Math.max(ratio, this.zoomService.baseRatio || 0); 110 | } 111 | 112 | @Input('maxZoomRatio') 113 | public set setMaxZoomRatio(maxZoomRatio: number) { 114 | this.zoomService.maxZoomRatio = Number(maxZoomRatio) || this.zoomService.maxZoomRatio; 115 | } 116 | 117 | @Input('scrollStepSize') 118 | public set setScrollStepSize(stepSize: number) { 119 | this.scrollStepSize = Number(stepSize) || this.scrollStepSize; 120 | } 121 | 122 | @Input('enableLens') 123 | public set setEnableLens(enable: boolean) { 124 | this.zoomService.enableLens = Boolean(enable); 125 | } 126 | 127 | @Input('lensWidth') 128 | public set setLensWidth(width: number) { 129 | this.zoomService.lensWidth = Number(width) || this.zoomService.lensWidth; 130 | } 131 | 132 | @Input('lensHeight') 133 | public set setLensHeight(height: number) { 134 | this.zoomService.lensHeight = Number(height) || this.zoomService.lensHeight; 135 | } 136 | 137 | @Input('circularLens') 138 | public set setCircularLens(enable: boolean) { 139 | this.circularLens = Boolean(enable); 140 | } 141 | 142 | @Input('enableScrollZoom') 143 | public set setEnableScrollZoom(enable: boolean) { 144 | this.enableScrollZoom = Boolean(enable); 145 | } 146 | 147 | @Input() altText = ''; 148 | @Input() titleText = ''; 149 | 150 | ngOnInit(): void { 151 | // If no full size image is defined, we add the thumbnail as the full size too. 152 | if (this.fullImage === undefined) { 153 | this.fullImage = this.thumbImage; 154 | } 155 | 156 | this.registerServiceSubscriptions(); 157 | 158 | // Load zoom mode and set up configuration. 159 | this.loadZoomMode(); 160 | this.registerEventListeners(); 161 | this.calculateLensBorder(); 162 | } 163 | 164 | ngOnChanges() { 165 | this.calculateLensBorder(); 166 | this.zoomService.calculateRatioAndOffset(); 167 | this.zoomService.calculateImageAndLensPosition(); 168 | } 169 | 170 | ngOnDestroy(): void { 171 | this.subscriptions.forEach((subscription) => subscription.unsubscribe()); 172 | this.eventListeners.forEach((destroyFn) => destroyFn()); 173 | } 174 | 175 | private registerServiceSubscriptions() { 176 | this.subscriptions.push( 177 | this.zoomService.zoomPosition.subscribe((position) => this.zoomPosition.emit(position)) 178 | ); 179 | } 180 | 181 | private loadZoomMode(): void { 182 | const ZoomModeClass = this.zoomModesMap.get(this.zoomMode); 183 | if (ZoomModeClass) { 184 | this.zoomInstance = new ZoomModeClass(this.zoomService); 185 | } else { 186 | console.error(`Unsupported zoom mode: ${this.zoomMode}`); 187 | } 188 | } 189 | 190 | private registerEventListeners(): void { 191 | if (this.zoomInstance) { 192 | const nativeElement = this.zoomContainer.nativeElement; 193 | 194 | this.eventListeners.push( 195 | this.renderer.listen(nativeElement, 'mouseenter', (event) => this.zoomInstance.onMouseEnter(event)), 196 | this.renderer.listen(nativeElement, 'mouseleave', (event) => this.zoomInstance.onMouseLeave(event)), 197 | this.renderer.listen(nativeElement, 'mousemove', (event) => this.zoomInstance.onMouseMove(event)), 198 | this.renderer.listen(nativeElement, 'click', (event) => this.zoomInstance.onClick(event)), 199 | 200 | // Chrome: 'mousewheel', Firefox: 'DOMMouseScroll', IE: 'onmousewheel' 201 | this.renderer.listen(nativeElement, 'mousewheel', (event) => { 202 | if (this.zoomInstance.onMouseWheel(event)) { 203 | this.onMouseWheel(event); 204 | } 205 | }), 206 | this.renderer.listen(nativeElement, 'DOMMouseScroll', (event) => { 207 | if (this.zoomInstance.onMouseWheel(event)) { 208 | this.onMouseWheel(event); 209 | } 210 | }), 211 | this.renderer.listen(nativeElement, 'onmousewheel', (event) => { 212 | if (this.zoomInstance.onMouseWheel(event)) { 213 | this.onMouseWheel(event); 214 | } 215 | }) 216 | ); 217 | } 218 | } 219 | 220 | /** 221 | * Template helper methods 222 | */ 223 | onThumbImageLoaded() { 224 | // Pass along image sizes to the service. 225 | this.zoomService.thumbWidth = this.imageThumbnail.nativeElement.width; 226 | this.zoomService.thumbHeight = this.imageThumbnail.nativeElement.height; 227 | this.thumbImageLoaded = true; 228 | this.checkImagesLoaded(); 229 | } 230 | 231 | onFullImageLoaded() { 232 | // Pass along image sizes to the service. 233 | this.zoomService.fullWidth = this.fullSizeImage.nativeElement.naturalWidth; 234 | this.zoomService.fullHeight = this.fullSizeImage.nativeElement.naturalHeight; 235 | this.zoomService.fullImageLoaded = true; 236 | this.checkImagesLoaded(); 237 | } 238 | 239 | private calculateLensBorder() { 240 | if (this.zoomService.enableLens) { 241 | if (this.circularLens) { 242 | this.lensBorderRadius = this.zoomService.lensWidth / 2; 243 | } else { 244 | this.lensBorderRadius = 0; 245 | } 246 | } 247 | } 248 | 249 | private checkImagesLoaded() { 250 | this.zoomService.calculateRatioAndOffset(); 251 | if (this.thumbImageLoaded && this.zoomService.fullImageLoaded) { 252 | this.zoomService.calculateImageAndLensPosition(); 253 | this.setIsReady(true); 254 | } 255 | } 256 | 257 | private setIsReady(value: boolean) { 258 | this.zoomService.isReady = value; 259 | this.imagesLoaded.emit(value); 260 | } 261 | 262 | /** 263 | * Mouse wheel event 264 | */ 265 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 266 | private onMouseWheel(event: any) { 267 | // Don't eat events if scrollZoom or zooming isn't active 268 | if (!this.enableScrollZoom || !this.zoomService.zoomingEnabled) { 269 | return; 270 | } 271 | 272 | event = window.event || event; // old IE 273 | const direction = Math.max(Math.min(event.wheelDelta || -event.detail, 1), -1); 274 | if (direction > 0) { 275 | // up 276 | this.setMagnification = Math.min( 277 | this.zoomService.magnification + this.scrollStepSize, 278 | this.zoomService.maxZoomRatio 279 | ); 280 | } else { 281 | // down 282 | this.setMagnification = Math.max( 283 | this.zoomService.magnification - this.scrollStepSize, 284 | this.zoomService.minZoomRatio 285 | ); 286 | } 287 | this.zoomService.calculateRatio(); 288 | this.zoomService.calculateZoomPosition(event); 289 | 290 | // Prevent scrolling on page. 291 | event.returnValue = false; // IE 292 | if (event.preventDefault) { 293 | event.preventDefault(); // Chrome & FF 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /src/lib/ngx-image-zoom.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { NgxImageZoomComponent } from './ngx-image-zoom.component'; 3 | import { CommonModule } from '@angular/common'; 4 | 5 | @NgModule({ 6 | declarations: [NgxImageZoomComponent], 7 | imports: [CommonModule], 8 | exports: [NgxImageZoomComponent], 9 | }) 10 | export class NgxImageZoomModule {} 11 | -------------------------------------------------------------------------------- /src/lib/ngx-image-zoom.service.spec.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-string-literal */ 2 | import { ChangeDetectorRef } from '@angular/core'; 3 | import { Coord } from './ngx-image-zoom.component'; 4 | import { NgxImageZoomService } from './ngx-image-zoom.service'; 5 | import { BehaviorSubject } from 'rxjs'; 6 | 7 | describe('NgxImageZoomService', () => { 8 | let zoomService: NgxImageZoomService; 9 | let changeDetectorRefMock: ChangeDetectorRef; 10 | 11 | beforeEach(() => { 12 | changeDetectorRefMock = jasmine.createSpyObj('ChangeDetectorRef', ['markForCheck']); 13 | zoomService = new NgxImageZoomService(changeDetectorRefMock); 14 | }); 15 | 16 | it('should initialize with default values', () => { 17 | expect(zoomService.zoomDisplay).toBe('none'); 18 | expect(zoomService.thumbWidth).toBe(0); 19 | expect(zoomService.thumbHeight).toBe(0); 20 | expect(zoomService.fullImageTop).toBe(0); 21 | expect(zoomService.fullImageLeft).toBe(0); 22 | expect(zoomService.lensWidth).toBe(100); 23 | expect(zoomService.lensHeight).toBe(100); 24 | expect(zoomService.lensTop).toBe(0); 25 | expect(zoomService.lensLeft).toBe(0); 26 | expect(zoomService.magnifiedWidth).toBe(0); 27 | expect(zoomService.magnifiedHeight).toBe(0); 28 | expect(zoomService.zoomPosition).toEqual(new BehaviorSubject(null)); 29 | expect(zoomService.zoomingEnabled).toBe(false); 30 | expect(zoomService.isReady).toBe(false); 31 | expect(zoomService.enableLens).toBe(false); 32 | expect(zoomService.baseRatio).toBeUndefined(); 33 | expect(zoomService.minZoomRatio).toBe(1); 34 | expect(zoomService.maxZoomRatio).toBe(2); 35 | expect(zoomService.magnification).toBe(1); 36 | expect(zoomService.fullImageLoaded).toBeFalsy(); 37 | expect(zoomService.fullWidth).toBe(0); 38 | expect(zoomService.fullHeight).toBe(0); 39 | }); 40 | 41 | it('should enable zooming and calculate ratios and offsets when zoomOn is called', () => { 42 | zoomService.isReady = true; 43 | zoomService.thumbWidth = 200; 44 | zoomService.thumbHeight = 150; 45 | zoomService.fullImageLoaded = true; 46 | zoomService.fullWidth = 800; 47 | zoomService.fullHeight = 600; 48 | const eventMock = new MouseEvent('click'); 49 | 50 | zoomService.zoomOn(eventMock); 51 | 52 | expect(zoomService.zoomingEnabled).toBe(true); 53 | expect(zoomService.zoomDisplay).toBe('block'); 54 | expect(zoomService.baseRatio).toBe(0.25); 55 | expect(zoomService.minZoomRatio).toBe(1); 56 | expect(zoomService.magnifiedWidth).toBe(800); 57 | expect(zoomService.magnifiedHeight).toBe(600); 58 | expect(changeDetectorRefMock.markForCheck).toHaveBeenCalled(); 59 | }); 60 | 61 | it('should disable zooming when zoomOff is called', () => { 62 | zoomService.zoomingEnabled = true; 63 | 64 | zoomService.zoomOff(); 65 | 66 | expect(zoomService.zoomingEnabled).toBe(false); 67 | expect(zoomService.zoomDisplay).toBe('none'); 68 | expect(changeDetectorRefMock.markForCheck).toHaveBeenCalled(); 69 | }); 70 | 71 | it('should update the zoom position and calculate image and lens positions when calculateZoomPosition is called', () => { 72 | zoomService.enableLens = true; 73 | zoomService.thumbWidth = 200; 74 | zoomService.thumbHeight = 150; 75 | zoomService.fullWidth = 400; 76 | zoomService.fullHeight = 300; 77 | zoomService.magnification = 2; 78 | 79 | const eventMock = jasmine.createSpyObj('MouseEvent', ['preventDefault']); 80 | Object.defineProperty(eventMock, 'offsetX', { value: 100 }); 81 | Object.defineProperty(eventMock, 'offsetY', { value: 75 }); 82 | 83 | zoomService.calculateRatio(); 84 | zoomService.calculateZoomPosition(eventMock); 85 | 86 | expect(zoomService.lensLeft).toBe(50); 87 | expect(zoomService.lensTop).toBe(25); 88 | expect(zoomService.fullImageLeft).toBe(-350); 89 | expect(zoomService.fullImageTop).toBe(-250); 90 | expect(zoomService.zoomPosition.value.x).toBe(100); 91 | expect(zoomService.zoomPosition.value.y).toBe(75); 92 | expect(changeDetectorRefMock.markForCheck).toHaveBeenCalled(); 93 | }); 94 | 95 | it('should call markForCheck when markForCheck is called', () => { 96 | zoomService.markForCheck(); 97 | 98 | expect(changeDetectorRefMock.markForCheck).toHaveBeenCalled(); 99 | }); 100 | 101 | it('should update lens size and position when calculateRatioAndOffset is called with lens disabled', () => { 102 | zoomService.enableLens = false; 103 | zoomService.thumbWidth = 200; 104 | zoomService.thumbHeight = 150; 105 | zoomService.fullImageLoaded = true; 106 | zoomService.fullWidth = 800; 107 | zoomService.fullHeight = 600; 108 | 109 | zoomService.calculateRatioAndOffset(); 110 | 111 | expect(zoomService.lensWidth).toBe(200); 112 | expect(zoomService.lensHeight).toBe(150); 113 | expect(zoomService.lensLeft).toBe(0); 114 | expect(zoomService.lensTop).toBe(0); 115 | expect(zoomService.baseRatio).toBe(0.25); 116 | expect(zoomService.minZoomRatio).toBe(1); 117 | }); 118 | 119 | it('should update lens size and position when calculateRatioAndOffset is called with lens enabled', () => { 120 | zoomService.enableLens = true; 121 | zoomService.thumbWidth = 200; 122 | zoomService.thumbHeight = 150; 123 | zoomService.fullImageLoaded = true; 124 | zoomService.fullWidth = 800; 125 | zoomService.fullHeight = 600; 126 | 127 | zoomService.calculateRatioAndOffset(); 128 | 129 | expect(zoomService.lensWidth).toBe(100); 130 | expect(zoomService.lensHeight).toBe(100); 131 | expect(zoomService.lensLeft).toBe(0); 132 | expect(zoomService.lensTop).toBe(0); 133 | expect(zoomService.baseRatio).toBe(0.25); 134 | expect(zoomService.minZoomRatio).toBe(1); 135 | }); 136 | 137 | it('should calculate magnified dimensions and ratios when calculateRatio is called', () => { 138 | zoomService.fullWidth = 800; 139 | zoomService.fullHeight = 600; 140 | zoomService.magnification = 2; 141 | 142 | zoomService.calculateRatio(); 143 | 144 | expect(zoomService.magnifiedWidth).toBe(1600); 145 | expect(zoomService.magnifiedHeight).toBe(1200); 146 | // expect(zoomService.xRatio).toBe(8); 147 | // expect(zoomService.yRatio).toBe(8); 148 | }); 149 | 150 | it('should not update lens positions when enableLens is false or latestMouseLeft is not set when calculateImageAndLensPosition is called', () => { 151 | zoomService.enableLens = false; 152 | zoomService.lensWidth = 100; 153 | zoomService.lensHeight = 100; 154 | 155 | zoomService.calculateImageAndLensPosition(); 156 | 157 | expect(zoomService.lensLeft).toBe(0); 158 | expect(zoomService.lensTop).toBe(0); 159 | expect(zoomService.fullImageLeft).toBe(0); 160 | expect(zoomService.fullImageTop).toBe(0); 161 | 162 | zoomService.enableLens = true; 163 | 164 | zoomService.calculateImageAndLensPosition(); 165 | 166 | expect(zoomService.lensLeft).toBe(0); 167 | expect(zoomService.lensTop).toBe(0); 168 | expect(zoomService.fullImageLeft).toBe(0); 169 | expect(zoomService.fullImageTop).toBe(0); 170 | }); 171 | }); 172 | -------------------------------------------------------------------------------- /src/lib/ngx-image-zoom.service.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectorRef, Injectable } from '@angular/core'; 2 | import { BehaviorSubject } from 'rxjs'; 3 | import { Coord } from './ngx-image-zoom.component'; 4 | 5 | @Injectable() 6 | export class NgxImageZoomService { 7 | public zoomDisplay = 'none'; 8 | public thumbWidth = 0; 9 | public thumbHeight = 0; 10 | public fullImageTop = 0; 11 | public fullImageLeft = 0; 12 | public lensWidth = 100; 13 | public lensHeight = 100; 14 | public lensTop = 0; 15 | public lensLeft = 0; 16 | public magnifiedWidth = 0; 17 | public magnifiedHeight = 0; 18 | public zoomPosition = new BehaviorSubject(null); 19 | 20 | public zoomingEnabled = false; 21 | public isReady = false; 22 | public enableLens = false; 23 | public baseRatio?: number; 24 | public minZoomRatio = 1; 25 | public maxZoomRatio = 2; 26 | public magnification = 1; 27 | 28 | public fullImageLoaded: boolean; 29 | 30 | public fullWidth = 0; 31 | public fullHeight = 0; 32 | private xRatio = 0; 33 | private yRatio = 0; 34 | private latestMouseLeft = -1; 35 | private latestMouseTop = -1; 36 | 37 | constructor(private changeDetectorRef: ChangeDetectorRef) {} 38 | 39 | public zoomOn(event: MouseEvent) { 40 | if (this.isReady) { 41 | this.zoomingEnabled = true; 42 | this.calculateRatioAndOffset(); 43 | this.zoomDisplay = 'block'; 44 | this.calculateZoomPosition(event); 45 | this.changeDetectorRef.markForCheck(); 46 | } 47 | } 48 | 49 | public zoomOff() { 50 | this.zoomingEnabled = false; 51 | this.zoomDisplay = 'none'; 52 | this.changeDetectorRef.markForCheck(); 53 | } 54 | 55 | public markForCheck() { 56 | this.changeDetectorRef.markForCheck(); 57 | } 58 | 59 | calculateRatioAndOffset() { 60 | // If lens is disabled, set lens size to equal thumb size and position it on top of the thumb 61 | if (!this.enableLens) { 62 | this.lensWidth = this.thumbWidth; 63 | this.lensHeight = this.thumbHeight; 64 | this.lensLeft = 0; 65 | this.lensTop = 0; 66 | } 67 | 68 | if (this.fullImageLoaded) { 69 | this.baseRatio = Math.max(this.thumbWidth / this.fullWidth, this.thumbHeight / this.fullHeight); 70 | 71 | // Don't allow zooming to smaller than thumbnail size 72 | this.minZoomRatio = Math.max(this.minZoomRatio || 0, this.baseRatio || 0); 73 | 74 | this.calculateRatio(); 75 | } 76 | } 77 | 78 | calculateRatio() { 79 | this.magnifiedWidth = this.fullWidth * this.magnification; 80 | this.magnifiedHeight = this.fullHeight * this.magnification; 81 | 82 | this.xRatio = (this.magnifiedWidth - this.thumbWidth) / this.thumbWidth; 83 | this.yRatio = (this.magnifiedHeight - this.thumbHeight) / this.thumbHeight; 84 | } 85 | 86 | calculateZoomPosition(event: MouseEvent) { 87 | const newLeft = Math.max(Math.min(event.offsetX, this.thumbWidth), 0); 88 | const newTop = Math.max(Math.min(event.offsetY, this.thumbHeight), 0); 89 | 90 | this.setZoomPosition(newLeft, newTop); 91 | 92 | this.calculateImageAndLensPosition(); 93 | 94 | this.changeDetectorRef.markForCheck(); 95 | } 96 | 97 | calculateImageAndLensPosition() { 98 | let lensLeftMod = 0; 99 | let lensTopMod = 0; 100 | 101 | if (this.enableLens && this.latestMouseLeft > 0) { 102 | lensLeftMod = this.latestMouseLeft - this.lensWidth / 2; 103 | lensTopMod = this.latestMouseTop - this.lensHeight / 2; 104 | this.lensLeft = lensLeftMod; 105 | this.lensTop = lensTopMod; 106 | } 107 | 108 | this.fullImageLeft = this.latestMouseLeft * -this.xRatio - lensLeftMod; 109 | this.fullImageTop = this.latestMouseTop * -this.yRatio - lensTopMod; 110 | } 111 | 112 | private setZoomPosition(left: number, top: number) { 113 | this.latestMouseLeft = Number(left) || this.latestMouseLeft; 114 | this.latestMouseTop = Number(top) || this.latestMouseTop; 115 | 116 | const newPosition: Coord = { 117 | x: this.latestMouseLeft, 118 | y: this.latestMouseTop, 119 | }; 120 | this.zoomPosition.next(newPosition); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/click-zoom-mode.spec.ts: -------------------------------------------------------------------------------- 1 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 2 | import { ClickZoomMode } from './click-zoom-mode'; 3 | import SpyObj = jasmine.SpyObj; 4 | 5 | describe('ClickZoomMode', () => { 6 | let zoomMode: ClickZoomMode; 7 | let zoomServiceMock: SpyObj; 8 | 9 | beforeEach(() => { 10 | zoomServiceMock = jasmine.createSpyObj('NgxImageZoomService', ['zoomOn', 'zoomOff', 'calculateZoomPosition']); 11 | 12 | zoomMode = new ClickZoomMode(zoomServiceMock); 13 | }); 14 | 15 | it('should call zoomOn when clicking and zooming is disabled', () => { 16 | const eventMock = new MouseEvent('click'); 17 | zoomServiceMock.zoomingEnabled = false; 18 | 19 | zoomMode.onClick(eventMock); 20 | 21 | expect(zoomServiceMock.zoomOn).toHaveBeenCalledWith(eventMock); 22 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 23 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 24 | }); 25 | 26 | it('should do nothing when mouse enters', () => { 27 | zoomMode.onMouseEnter(); 28 | 29 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 30 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 31 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 32 | }); 33 | 34 | it('should call zoomOff when mouse leaves', () => { 35 | zoomMode.onMouseLeave(); 36 | 37 | expect(zoomServiceMock.zoomOff).toHaveBeenCalled(); 38 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 39 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 40 | }); 41 | 42 | it('should call calculateZoomPosition when mouse moves and zooming is enabled', () => { 43 | const eventMock = new MouseEvent('mousemove'); 44 | zoomServiceMock.zoomingEnabled = true; 45 | 46 | zoomMode.onMouseMove(eventMock); 47 | 48 | expect(zoomServiceMock.calculateZoomPosition).toHaveBeenCalledWith(eventMock); 49 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 50 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 51 | }); 52 | 53 | it('should not call calculateZoomPosition when mouse moves and zooming is disabled', () => { 54 | const eventMock = new MouseEvent('mousemove'); 55 | zoomServiceMock.zoomingEnabled = false; 56 | 57 | zoomMode.onMouseMove(eventMock); 58 | 59 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 60 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 61 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 62 | }); 63 | 64 | it('should return true for onMouseWheel', () => { 65 | const result = zoomMode.onMouseWheel(); 66 | 67 | expect(result).toBe(true); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/click-zoom-mode.ts: -------------------------------------------------------------------------------- 1 | import { ZoomMode } from './zoom-mode'; 2 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 3 | 4 | export class ClickZoomMode implements ZoomMode { 5 | constructor(private zoomService: NgxImageZoomService) {} 6 | 7 | onClick(event: MouseEvent): void { 8 | if (this.zoomService.zoomingEnabled === false) { 9 | this.zoomService.zoomOn(event); 10 | } 11 | } 12 | 13 | onMouseEnter(): void { 14 | // NOP 15 | } 16 | 17 | onMouseLeave(): void { 18 | this.zoomService.zoomOff(); 19 | } 20 | 21 | onMouseMove(event: MouseEvent): void { 22 | if (this.zoomService.zoomingEnabled) { 23 | this.zoomService.calculateZoomPosition(event); 24 | } 25 | } 26 | 27 | onMouseWheel(): boolean { 28 | return true; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/hover-freeze-zoom-mode.spec.ts: -------------------------------------------------------------------------------- 1 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 2 | import { HoverFreezeZoomMode } from './hover-freeze-zoom-mode'; 3 | import SpyObj = jasmine.SpyObj; 4 | 5 | describe('HoverFreezeZoomMode', () => { 6 | let zoomMode: HoverFreezeZoomMode; 7 | let zoomServiceMock: SpyObj; 8 | 9 | beforeEach(() => { 10 | zoomServiceMock = jasmine.createSpyObj('NgxImageZoomService', [ 11 | 'zoomingEnabled', 12 | 'zoomOn', 13 | 'zoomOff', 14 | 'calculateZoomPosition', 15 | 'markForCheck', 16 | ]); 17 | 18 | zoomMode = new HoverFreezeZoomMode(zoomServiceMock); 19 | }); 20 | 21 | describe('getting click events', () => { 22 | const eventMock = new MouseEvent('click'); 23 | 24 | it('should call zoomOn when zooming is disabled and clicked for the first time', () => { 25 | zoomServiceMock.zoomingEnabled = false; 26 | 27 | zoomMode.onClick(eventMock); 28 | 29 | expect(zoomServiceMock.zoomOn).toHaveBeenCalledWith(eventMock); 30 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 31 | expect(zoomServiceMock.markForCheck).not.toHaveBeenCalled(); 32 | }); 33 | 34 | it('should call markForCheck when zooming is enabled and clicked while frozen', () => { 35 | zoomServiceMock.zoomingEnabled = true; 36 | 37 | zoomMode.onClick(eventMock); // Freeze zoom 38 | 39 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 40 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 41 | expect(zoomServiceMock.markForCheck).toHaveBeenCalledTimes(1); 42 | }); 43 | 44 | it('should call markForCheck when freezing and nothing when unfreezing', () => { 45 | zoomServiceMock.zoomingEnabled = true; 46 | 47 | zoomMode.onClick(eventMock); // Freeze zoom 48 | expect(zoomServiceMock.markForCheck).toHaveBeenCalledTimes(1); 49 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 50 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 51 | zoomServiceMock.markForCheck.calls.reset(); 52 | 53 | zoomMode.onClick(eventMock); // Unfreeze zoom 54 | expect(zoomServiceMock.markForCheck).not.toHaveBeenCalled(); 55 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 56 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 57 | 58 | zoomMode.onClick(eventMock); // Freeze zoom again 59 | expect(zoomServiceMock.markForCheck).toHaveBeenCalledTimes(1); 60 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 61 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 62 | }); 63 | 64 | it('should call zoomOn when zooming is disabled', () => { 65 | zoomServiceMock.zoomingEnabled = false; 66 | 67 | zoomMode.onClick(eventMock); 68 | 69 | expect(zoomServiceMock.zoomOn).toHaveBeenCalledWith(eventMock); 70 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 71 | expect(zoomServiceMock.markForCheck).not.toHaveBeenCalled(); 72 | }); 73 | }); 74 | 75 | describe('when getting mouseEnter events', () => { 76 | const eventMock = new MouseEvent('mouseenter'); 77 | 78 | it('should activate zoom when not frozen', () => { 79 | zoomMode.onMouseEnter(eventMock); 80 | 81 | expect(zoomServiceMock.zoomOn).toHaveBeenCalledTimes(1); 82 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 83 | expect(zoomServiceMock.markForCheck).not.toHaveBeenCalled(); 84 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 85 | }); 86 | 87 | it('should do nothing when frozen', () => { 88 | zoomServiceMock.zoomingEnabled = true; 89 | zoomMode.onClick(new MouseEvent('click')); // Freeze zoom. 90 | zoomServiceMock.markForCheck.calls.reset(); 91 | 92 | zoomMode.onMouseEnter(eventMock); 93 | 94 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 95 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 96 | expect(zoomServiceMock.markForCheck).not.toHaveBeenCalled(); 97 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 98 | }); 99 | }); 100 | 101 | describe('when getting mouseLeave events', () => { 102 | it('should call zoomOff when mouse leaves and zooming is enabled and not frozen', () => { 103 | zoomServiceMock.zoomingEnabled = true; 104 | 105 | zoomMode.onMouseLeave(); 106 | 107 | expect(zoomServiceMock.zoomOff).toHaveBeenCalledTimes(1); 108 | }); 109 | 110 | it('should not call zoomOff when mouse leaves and zooming is disabled', () => { 111 | zoomServiceMock.zoomingEnabled = false; 112 | 113 | zoomMode.onMouseLeave(); 114 | 115 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 116 | }); 117 | 118 | it('should not call zoomOff when mouse leaves and zooming is enabled but frozen', () => { 119 | zoomServiceMock.zoomingEnabled = true; 120 | zoomMode.onClick(new MouseEvent('click')); // Freeze zoom 121 | 122 | zoomMode.onMouseLeave(); 123 | 124 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 125 | }); 126 | }); 127 | 128 | describe('when getting mouseMove events', () => { 129 | const eventMock = new MouseEvent('mousemove'); 130 | 131 | it('should call calculateZoomPosition when mouse moves and zooming is enabled and not frozen', () => { 132 | zoomServiceMock.zoomingEnabled = true; 133 | 134 | zoomMode.onMouseMove(eventMock); 135 | 136 | expect(zoomServiceMock.calculateZoomPosition).toHaveBeenCalledWith(eventMock); 137 | }); 138 | 139 | it('should not call calculateZoomPosition when mouse moves and zooming is enabled while frozen', () => { 140 | zoomServiceMock.zoomingEnabled = true; 141 | zoomMode.onClick(new MouseEvent('click')); // Freeze zoom. 142 | 143 | zoomMode.onMouseMove(eventMock); 144 | 145 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 146 | }); 147 | 148 | it('should not call calculateZoomPosition when mouse moves and zooming is disabled', () => { 149 | zoomServiceMock.zoomingEnabled = false; 150 | 151 | zoomMode.onMouseMove(eventMock); 152 | 153 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 154 | }); 155 | }); 156 | 157 | describe('getting mousewheel events', () => { 158 | it('should not allow zooming with mouse wheel when zoom is frozen', () => { 159 | zoomServiceMock.zoomingEnabled = true; 160 | zoomMode.onClick(new MouseEvent('click')); // Freeze zoom. 161 | 162 | const allowZoom = zoomMode.onMouseWheel(); 163 | 164 | expect(allowZoom).toBeFalse(); 165 | }); 166 | 167 | it('should allow zooming with mouse wheel when zoom is not frozen', () => { 168 | const allowZoom = zoomMode.onMouseWheel(); 169 | 170 | expect(allowZoom).toBeTrue(); 171 | }); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/hover-freeze-zoom-mode.ts: -------------------------------------------------------------------------------- 1 | import { ZoomMode } from './zoom-mode'; 2 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 3 | 4 | export class HoverFreezeZoomMode implements ZoomMode { 5 | private zoomFrozen = false; 6 | constructor(private zoomService: NgxImageZoomService) {} 7 | 8 | onClick(event: MouseEvent): void { 9 | if (this.zoomService.zoomingEnabled && this.zoomFrozen) { 10 | this.zoomFrozen = false; 11 | } else if (this.zoomService.zoomingEnabled) { 12 | this.zoomFrozen = true; 13 | this.zoomService.markForCheck(); 14 | } else { 15 | this.zoomService.zoomOn(event); 16 | } 17 | } 18 | 19 | onMouseEnter(event: MouseEvent): void { 20 | if (!this.zoomFrozen) { 21 | this.zoomService.zoomOn(event); 22 | } 23 | } 24 | 25 | onMouseLeave(): void { 26 | if (this.zoomService.zoomingEnabled && !this.zoomFrozen) { 27 | this.zoomService.zoomOff(); 28 | } 29 | } 30 | 31 | onMouseMove(event: MouseEvent): void { 32 | if (this.zoomService.zoomingEnabled && !this.zoomFrozen) { 33 | this.zoomService.calculateZoomPosition(event); 34 | } 35 | } 36 | 37 | onMouseWheel(): boolean { 38 | // Prevent scroll zoom if we're frozen 39 | return !this.zoomFrozen; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/hover-zoom-mode.spec.ts: -------------------------------------------------------------------------------- 1 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 2 | import { HoverZoomMode } from './hover-zoom-mode'; 3 | import SpyObj = jasmine.SpyObj; 4 | 5 | describe('HoverZoomMode', () => { 6 | let zoomMode: HoverZoomMode; 7 | let zoomServiceMock: SpyObj; 8 | 9 | beforeEach(() => { 10 | zoomServiceMock = jasmine.createSpyObj('NgxImageZoomService', ['zoomOn', 'zoomOff', 'calculateZoomPosition']); 11 | 12 | zoomMode = new HoverZoomMode(zoomServiceMock); 13 | }); 14 | 15 | it('should do nothing on mouse clicks', () => { 16 | zoomMode.onClick(); 17 | 18 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 19 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 20 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 21 | }); 22 | 23 | it('should call zoomOn when mouse enters', () => { 24 | const eventMock = new MouseEvent('mouseenter'); 25 | 26 | zoomMode.onMouseEnter(eventMock); 27 | 28 | expect(zoomServiceMock.zoomOn).toHaveBeenCalledWith(eventMock); 29 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 30 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 31 | }); 32 | 33 | it('should call zoomOff when mouse leaves', () => { 34 | zoomMode.onMouseLeave(); 35 | 36 | expect(zoomServiceMock.zoomOff).toHaveBeenCalled(); 37 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 38 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 39 | }); 40 | 41 | it('should call calculateZoomPosition when mouse moves', () => { 42 | const eventMock = new MouseEvent('mousemove'); 43 | 44 | zoomMode.onMouseMove(eventMock); 45 | 46 | expect(zoomServiceMock.calculateZoomPosition).toHaveBeenCalledWith(eventMock); 47 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 48 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 49 | }); 50 | 51 | it('should return true for onMouseWheel', () => { 52 | const result = zoomMode.onMouseWheel(); 53 | 54 | expect(result).toBe(true); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/hover-zoom-mode.ts: -------------------------------------------------------------------------------- 1 | import { ZoomMode } from './zoom-mode'; 2 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 3 | 4 | export class HoverZoomMode implements ZoomMode { 5 | constructor(private zoomService: NgxImageZoomService) {} 6 | 7 | onClick(): void { 8 | // NOP 9 | } 10 | 11 | onMouseEnter(event: MouseEvent): void { 12 | this.zoomService.zoomOn(event); 13 | } 14 | 15 | onMouseLeave(): void { 16 | this.zoomService.zoomOff(); 17 | } 18 | 19 | onMouseMove(event: MouseEvent): void { 20 | this.zoomService.calculateZoomPosition(event); 21 | } 22 | 23 | onMouseWheel(): boolean { 24 | return true; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/toggle-click-zoom-mode.spec.ts: -------------------------------------------------------------------------------- 1 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 2 | import { ToggleClickZoomMode } from './toggle-click-zoom-mode'; 3 | import SpyObj = jasmine.SpyObj; 4 | 5 | describe('ToggleClickZoomMode', () => { 6 | let zoomMode: ToggleClickZoomMode; 7 | let zoomServiceMock: SpyObj; 8 | 9 | beforeEach(() => { 10 | zoomServiceMock = jasmine.createSpyObj('NgxImageZoomService', ['zoomOn', 'zoomOff', 'calculateZoomPosition']); 11 | 12 | zoomMode = new ToggleClickZoomMode(zoomServiceMock); 13 | }); 14 | 15 | it('should call zoomOff when clicking while zooming is enabled', () => { 16 | const eventMock = new MouseEvent('click'); 17 | zoomServiceMock.zoomingEnabled = true; 18 | 19 | zoomMode.onClick(eventMock); 20 | 21 | expect(zoomServiceMock.zoomOff).toHaveBeenCalled(); 22 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 23 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 24 | }); 25 | 26 | it('should call zoomOn when clicking while zooming is disabled', () => { 27 | const eventMock = new MouseEvent('click'); 28 | zoomServiceMock.zoomingEnabled = false; 29 | 30 | zoomMode.onClick(eventMock); 31 | 32 | expect(zoomServiceMock.zoomOn).toHaveBeenCalledWith(eventMock); 33 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 34 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 35 | }); 36 | 37 | it('should do nothing when mouse enters', () => { 38 | zoomMode.onMouseEnter(); 39 | 40 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 41 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 42 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 43 | }); 44 | 45 | it('should call zoomOff when mouse leaves', () => { 46 | zoomMode.onMouseLeave(); 47 | 48 | expect(zoomServiceMock.zoomOff).toHaveBeenCalled(); 49 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 50 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 51 | }); 52 | 53 | it('should call calculateZoomPosition when mouse moves while zooming is enabled', () => { 54 | const eventMock = new MouseEvent('mousemove'); 55 | zoomServiceMock.zoomingEnabled = true; 56 | 57 | zoomMode.onMouseMove(eventMock); 58 | 59 | expect(zoomServiceMock.calculateZoomPosition).toHaveBeenCalledWith(eventMock); 60 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 61 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 62 | }); 63 | 64 | it('should not call calculateZoomPosition when mouse moves while zooming is disabled', () => { 65 | const eventMock = new MouseEvent('mousemove'); 66 | zoomServiceMock.zoomingEnabled = false; 67 | 68 | zoomMode.onMouseMove(eventMock); 69 | 70 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 71 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 72 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 73 | }); 74 | 75 | it('should return true for onMouseWheel', () => { 76 | const result = zoomMode.onMouseWheel(); 77 | 78 | expect(result).toBe(true); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/toggle-click-zoom-mode.ts: -------------------------------------------------------------------------------- 1 | import { ZoomMode } from './zoom-mode'; 2 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 3 | 4 | export class ToggleClickZoomMode implements ZoomMode { 5 | constructor(private zoomService: NgxImageZoomService) {} 6 | 7 | onClick(event: MouseEvent): void { 8 | if (this.zoomService.zoomingEnabled) { 9 | this.zoomService.zoomOff(); 10 | } else { 11 | this.zoomService.zoomOn(event); 12 | } 13 | } 14 | 15 | onMouseEnter(): void { 16 | // NOP 17 | } 18 | 19 | onMouseLeave(): void { 20 | this.zoomService.zoomOff(); 21 | } 22 | 23 | onMouseMove(event: MouseEvent): void { 24 | if (this.zoomService.zoomingEnabled) { 25 | this.zoomService.calculateZoomPosition(event); 26 | } 27 | } 28 | 29 | onMouseWheel(): boolean { 30 | return true; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/toggle-freeze-zoom-mode.spec.ts: -------------------------------------------------------------------------------- 1 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 2 | import { ToggleFreezeZoomMode } from './toggle-freeze-zoom-mode'; 3 | import SpyObj = jasmine.SpyObj; 4 | 5 | describe('ToggleFreezeZoomMode', () => { 6 | let zoomMode: ToggleFreezeZoomMode; 7 | let zoomServiceMock: SpyObj; 8 | 9 | beforeEach(() => { 10 | zoomServiceMock = jasmine.createSpyObj('NgxImageZoomService', [ 11 | 'zoomingEnabled', 12 | 'zoomOn', 13 | 'zoomOff', 14 | 'calculateZoomPosition', 15 | 'markForCheck', 16 | ]); 17 | 18 | zoomMode = new ToggleFreezeZoomMode(zoomServiceMock); 19 | }); 20 | 21 | describe('getting click events', () => { 22 | const eventMock = new MouseEvent('click'); 23 | 24 | it('should call zoomOn when zooming is disabled and clicked for the first time', () => { 25 | zoomServiceMock.zoomingEnabled = false; 26 | 27 | zoomMode.onClick(eventMock); 28 | 29 | expect(zoomServiceMock.zoomOn).toHaveBeenCalledWith(eventMock); 30 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 31 | expect(zoomServiceMock.markForCheck).not.toHaveBeenCalled(); 32 | }); 33 | 34 | it('should call markForCheck when zooming is enabled and clicked while frozen', () => { 35 | zoomServiceMock.zoomingEnabled = true; 36 | 37 | zoomMode.onClick(eventMock); // Freeze zoom 38 | zoomMode.onClick(eventMock); // Unfreeze zoom 39 | 40 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 41 | expect(zoomServiceMock.zoomOff).toHaveBeenCalled(); 42 | expect(zoomServiceMock.markForCheck).toHaveBeenCalledTimes(1); 43 | }); 44 | 45 | it('should call markForCheck when freezing, zoomOff when unfreezing while zooming is enabled', () => { 46 | zoomServiceMock.zoomingEnabled = true; 47 | 48 | zoomMode.onClick(eventMock); // Freeze zoom 49 | expect(zoomServiceMock.markForCheck).toHaveBeenCalledTimes(1); 50 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 51 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 52 | zoomServiceMock.markForCheck.calls.reset(); 53 | 54 | zoomMode.onClick(eventMock); // Unfreeze zoom 55 | expect(zoomServiceMock.markForCheck).not.toHaveBeenCalled(); 56 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 57 | expect(zoomServiceMock.zoomOff).toHaveBeenCalledTimes(1); 58 | zoomServiceMock.zoomOff.calls.reset(); 59 | 60 | zoomMode.onClick(eventMock); // Freeze zoom again 61 | expect(zoomServiceMock.markForCheck).toHaveBeenCalledTimes(1); 62 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 63 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 64 | }); 65 | 66 | it('should call zoomOn when zooming is disabled', () => { 67 | zoomServiceMock.zoomingEnabled = false; 68 | 69 | zoomMode.onClick(eventMock); 70 | 71 | expect(zoomServiceMock.zoomOn).toHaveBeenCalledWith(eventMock); 72 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 73 | expect(zoomServiceMock.markForCheck).not.toHaveBeenCalled(); 74 | }); 75 | }); 76 | 77 | describe('when getting mouseEnter events', () => { 78 | it('should do nothing', () => { 79 | zoomMode.onMouseEnter(); 80 | 81 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 82 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 83 | expect(zoomServiceMock.markForCheck).not.toHaveBeenCalled(); 84 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 85 | }); 86 | }); 87 | 88 | describe('when getting mouseLeave events', () => { 89 | it('should call zoomOff when mouse leaves and zooming is enabled and not frozen', () => { 90 | zoomServiceMock.zoomingEnabled = true; 91 | 92 | zoomMode.onMouseLeave(); 93 | 94 | expect(zoomServiceMock.zoomOff).toHaveBeenCalledTimes(1); 95 | }); 96 | 97 | it('should not call zoomOff when mouse leaves and zooming is disabled', () => { 98 | zoomServiceMock.zoomingEnabled = false; 99 | 100 | zoomMode.onMouseLeave(); 101 | 102 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 103 | }); 104 | 105 | it('should not call zoomOff when mouse leaves and zooming is enabled but frozen', () => { 106 | zoomServiceMock.zoomingEnabled = true; 107 | zoomMode.onClick(new MouseEvent('click')); // Freeze zoom 108 | 109 | zoomMode.onMouseLeave(); 110 | 111 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 112 | }); 113 | }); 114 | 115 | describe('when getting mouseMove events', () => { 116 | const eventMock = new MouseEvent('mousemove'); 117 | 118 | it('should call calculateZoomPosition when mouse moves and zooming is enabled and not frozen', () => { 119 | zoomServiceMock.zoomingEnabled = true; 120 | 121 | zoomMode.onMouseMove(eventMock); 122 | 123 | expect(zoomServiceMock.calculateZoomPosition).toHaveBeenCalledWith(eventMock); 124 | }); 125 | 126 | it('should not call calculateZoomPosition when mouse moves and zooming is enabled while frozen', () => { 127 | zoomServiceMock.zoomingEnabled = true; 128 | zoomMode.onClick(new MouseEvent('click')); // Freeze zoom. 129 | 130 | zoomMode.onMouseMove(eventMock); 131 | 132 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 133 | }); 134 | 135 | it('should not call calculateZoomPosition when mouse moves and zooming is disabled', () => { 136 | zoomServiceMock.zoomingEnabled = false; 137 | 138 | zoomMode.onMouseMove(eventMock); 139 | 140 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 141 | }); 142 | }); 143 | 144 | describe('getting mousewheel events', () => { 145 | it('should not allow zooming with mouse wheel when zoom is frozen', () => { 146 | zoomServiceMock.zoomingEnabled = true; 147 | zoomMode.onClick(new MouseEvent('click')); // Freeze zoom. 148 | 149 | const allowZoom = zoomMode.onMouseWheel(); 150 | 151 | expect(allowZoom).toBeFalse(); 152 | }); 153 | 154 | it('should allow zooming with mouse wheel when zoom is not frozen', () => { 155 | const allowZoom = zoomMode.onMouseWheel(); 156 | 157 | expect(allowZoom).toBeTrue(); 158 | }); 159 | }); 160 | }); 161 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/toggle-freeze-zoom-mode.ts: -------------------------------------------------------------------------------- 1 | import { ZoomMode } from './zoom-mode'; 2 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 3 | 4 | export class ToggleFreezeZoomMode implements ZoomMode { 5 | private zoomFrozen = false; 6 | constructor(private zoomService: NgxImageZoomService) {} 7 | 8 | onClick(event: MouseEvent): void { 9 | if (this.zoomService.zoomingEnabled && this.zoomFrozen) { 10 | this.zoomFrozen = false; 11 | this.zoomService.zoomOff(); 12 | } else if (this.zoomService.zoomingEnabled) { 13 | this.zoomFrozen = true; 14 | this.zoomService.markForCheck(); 15 | } else { 16 | this.zoomService.zoomOn(event); 17 | } 18 | } 19 | 20 | onMouseEnter(): void { 21 | // NOP 22 | } 23 | 24 | onMouseLeave(): void { 25 | if (this.zoomService.zoomingEnabled && !this.zoomFrozen) { 26 | this.zoomService.zoomOff(); 27 | } 28 | } 29 | 30 | onMouseMove(event: MouseEvent): void { 31 | if (this.zoomService.zoomingEnabled && !this.zoomFrozen) { 32 | this.zoomService.calculateZoomPosition(event); 33 | } 34 | } 35 | 36 | onMouseWheel(): boolean { 37 | // Prevent scroll zoom if we're frozen 38 | return !this.zoomFrozen; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/toggle-zoom-mode.spec.ts: -------------------------------------------------------------------------------- 1 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 2 | import { ToggleZoomMode } from './toggle-zoom-mode'; 3 | import SpyObj = jasmine.SpyObj; 4 | 5 | describe('ToggleZoomMode', () => { 6 | let zoomMode: ToggleZoomMode; 7 | let zoomServiceMock: SpyObj; 8 | 9 | beforeEach(() => { 10 | zoomServiceMock = jasmine.createSpyObj('NgxImageZoomService', ['zoomOn', 'zoomOff', 'calculateZoomPosition']); 11 | 12 | zoomMode = new ToggleZoomMode(zoomServiceMock); 13 | }); 14 | 15 | it('should call zoomOff when clicking while zooming is enabled', () => { 16 | const eventMock = new MouseEvent('click'); 17 | zoomServiceMock.zoomingEnabled = true; 18 | 19 | zoomMode.onClick(eventMock); 20 | 21 | expect(zoomServiceMock.zoomOff).toHaveBeenCalled(); 22 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 23 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 24 | }); 25 | 26 | it('should call zoomOn when clicking while zooming is disabled', () => { 27 | const eventMock = new MouseEvent('click'); 28 | zoomServiceMock.zoomingEnabled = false; 29 | 30 | zoomMode.onClick(eventMock); 31 | 32 | expect(zoomServiceMock.zoomOn).toHaveBeenCalledWith(eventMock); 33 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 34 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 35 | }); 36 | 37 | it('should not do anything for onMouseEnter', () => { 38 | zoomMode.onMouseEnter(); 39 | 40 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 41 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 42 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 43 | }); 44 | 45 | it('should not do anything for onMouseLeave', () => { 46 | zoomMode.onMouseLeave(); 47 | 48 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 49 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 50 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 51 | }); 52 | 53 | it('should not do anything for onMouseMove', () => { 54 | zoomMode.onMouseMove(); 55 | 56 | expect(zoomServiceMock.calculateZoomPosition).not.toHaveBeenCalled(); 57 | expect(zoomServiceMock.zoomOn).not.toHaveBeenCalled(); 58 | expect(zoomServiceMock.zoomOff).not.toHaveBeenCalled(); 59 | }); 60 | 61 | it('should return true for onMouseWheel', () => { 62 | const result = zoomMode.onMouseWheel(); 63 | 64 | expect(result).toBe(true); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/toggle-zoom-mode.ts: -------------------------------------------------------------------------------- 1 | import { ZoomMode } from './zoom-mode'; 2 | import { NgxImageZoomService } from '../ngx-image-zoom.service'; 3 | 4 | export class ToggleZoomMode implements ZoomMode { 5 | constructor(private zoomService: NgxImageZoomService) {} 6 | 7 | onClick(event: MouseEvent): void { 8 | if (this.zoomService.zoomingEnabled) { 9 | this.zoomService.zoomOff(); 10 | } else { 11 | this.zoomService.zoomOn(event); 12 | } 13 | } 14 | 15 | onMouseEnter(): void { 16 | // NOP 17 | } 18 | onMouseLeave(): void { 19 | // NOP 20 | } 21 | onMouseMove(): void { 22 | // NOP 23 | } 24 | onMouseWheel(): boolean { 25 | return true; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/zoom-modes/zoom-mode.ts: -------------------------------------------------------------------------------- 1 | export interface ZoomMode { 2 | onClick(event: MouseEvent): void; 3 | onMouseEnter(event: MouseEvent): void; 4 | onMouseLeave(event: MouseEvent): void; 5 | onMouseMove(event: MouseEvent): void; 6 | // Return value decides if we will try to zoom with the wheel event. 7 | onMouseWheel(event: MouseEvent): boolean; 8 | } 9 | -------------------------------------------------------------------------------- /src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of ngx-image-zoom 3 | */ 4 | 5 | export * from './lib/ngx-image-zoom.component'; 6 | export * from './lib/ngx-image-zoom.module'; 7 | -------------------------------------------------------------------------------- /src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js'; 4 | import 'zone.js/testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 7 | 8 | // First, initialize the Angular testing environment. 9 | getTestBed().initTestEnvironment(BrowserDynamicTestingModule, platformBrowserDynamicTesting(), { 10 | teardown: { destroyAfterEach: false }, 11 | }); 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "downlevelIteration": true, 9 | "experimentalDecorators": true, 10 | "module": "es2020", 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "ES2022", 14 | "typeRoots": ["node_modules/@types"], 15 | "lib": ["es2018", "dom"], 16 | "paths": { 17 | "ngx-image-zoom": ["dist/ngx-image-zoom/ngx-image-zoom", "dist/ngx-image-zoom"] 18 | }, 19 | "useDefineForClassFields": false 20 | }, 21 | "angularCompilerOptions": { 22 | "fullTemplateTypeCheck": true, 23 | "strictInjectionParameters": true, 24 | "enableIvy": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/lib", 5 | "declarationMap": true, 6 | "declaration": true, 7 | "inlineSources": true, 8 | "types": [], 9 | "lib": ["dom", "es2018"] 10 | }, 11 | "angularCompilerOptions": { 12 | "skipTemplateCodegen": true, 13 | "strictMetadataEmit": true, 14 | "enableResourceInlining": true 15 | }, 16 | "exclude": ["src/test.ts", "**/*.spec.ts"] 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "files": ["src/test.ts"], 8 | "include": ["**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | --------------------------------------------------------------------------------