├── .eslintrc.js ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── demo ├── public │ ├── unsplash-large.jpg │ ├── unsplash.jpg │ ├── unsplash2-large.jpg │ ├── unsplash2.jpg │ ├── unsplash3-large.jpg │ └── unsplash3.jpg └── src │ └── index.js ├── nwb.config.js ├── package-lock.json ├── package.json ├── src ├── InnerImageZoom │ ├── InnerImageZoom.js │ ├── components │ │ ├── FullscreenPortal.js │ │ ├── Image.js │ │ └── ZoomImage.js │ ├── index.js │ ├── styles.css │ └── styles.min.css └── index.js └── tests ├── constants └── srcs.js └── index.spec.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true 6 | }, 7 | parserOptions: { 8 | ecmaVersion: 2020, 9 | sourceType: 'module', 10 | ecmaFeatures: { 11 | jsx: true 12 | } 13 | }, 14 | settings: { 15 | react: { 16 | version: 'detect' 17 | } 18 | }, 19 | extends: [ 20 | 'eslint:recommended', 21 | 'plugin:react/recommended', 22 | 'plugin:react-hooks/recommended', 23 | 'plugin:prettier/recommended' 24 | ], 25 | rules: { 26 | 'prettier/prettier': [ 27 | 'error', 28 | { 29 | singleQuote: true, 30 | jsxBracketSameLine: false, 31 | trailingComma: 'none', 32 | printWidth: 120, 33 | endOfLine: 'auto' 34 | } 35 | ] 36 | }, 37 | ignorePatterns: ['**/dist/**', '**/es/**', '**/lib/**'], 38 | overrides: [ 39 | { 40 | files: ['*.spec.js'], 41 | env: { 42 | mocha: true 43 | } 44 | } 45 | ] 46 | }; 47 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ['laurenashpole'] 4 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [ubuntu-latest, macos-latest, windows-latest] 17 | node-version: [12.x, 14.x, 16.x] 18 | include: 19 | - os: ubuntu-latest 20 | node-version: 16.x 21 | publish: true 22 | steps: 23 | - name: Checkout repo 24 | uses: actions/checkout@v2 25 | with: 26 | fetch-depth: 0 27 | - name: Setup Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v2 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | - name: Cache modules 32 | uses: actions/cache@v2 33 | with: 34 | path: ~/.npm 35 | key: v1-npm-deps-${{ hashFiles('**/package-lock.json') }} 36 | restore-keys: v1-npm-deps- 37 | - name: Install modules 38 | run: npm install 39 | - name: Run tests 40 | run: npm test 41 | - name: Publish 42 | if: matrix.publish && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 46 | run: | 47 | npm run build 48 | npm run semantic-release 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log* 8 | .DS_Store -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [3.0.2](https://github.com/laurenashpole/react-inner-image-zoom/compare/v3.0.1...v3.0.2) (2022-07-22) 4 | 5 | 6 | ### Fixed 7 | 8 | - A bug re-zooming after clicking the close button on non-touch devices when `zoomPreload` is false. 9 | 10 | 🎉🎉🎉 Special thanks to [MaxDAyala](https://github.com/MaxdAyala) for tackling the following: 11 | 12 | - A Firefox error when the zoomed image is dragged to the far left of the container. 13 | - The timing of the fade out `visibility` and `opacity` transitions. 14 | - An intermittent issue where zooming became disabled by panning in and out at a fast speed. 15 | 16 | ## [3.0.1](https://github.com/laurenashpole/react-inner-image-zoom/compare/v3.0.0...v3.0.1) (2022-06-12) 17 | 18 | ### Fixed 19 | 20 | - Added `prop-types` to the `peerDependencies`. 21 | 22 | ## [3.0.0](https://github.com/laurenashpole/react-inner-image-zoom/compare/v2.1.0...v3.0.0) (2022-01-03) 23 | 24 | ### Changed 25 | 26 | - Replaced `srcSet`, `sizes`, `alt`, and `title` props with `imgAttributes` to set the original image's attributes. 27 | - Show close button when moveType is set to "drag" on all breakpoints. 28 | - Switched from `setTimeout` to `onTransitionEnd` to check that zoomed image has finished fading out. 29 | 30 | ### Added 31 | 32 | - This handy CHANGELOG. 33 | 34 | ### Fixed 35 | 36 | - Added `stopPropagation` on touchmove to prevent events below fullscreen modal. 37 | 38 | ## [2.1.0](https://github.com/laurenashpole/react-inner-image-zoom/compare/v2.0.3...v2.1.0) (2021-08-30) 39 | 40 | ### Added 41 | 42 | - `title` prop to add attribute to original image. 43 | 44 | ## [2.0.3](https://github.com/laurenashpole/react-inner-image-zoom/compare/v2.0.2...v2.0.3) (2021-08-05) 45 | 46 | ### Changed 47 | 48 | - Use `touch-action` CSS property instead of `preventDefault` to prevent scroll on touchmove and drag. 49 | 50 | ### Fixed 51 | 52 | - Sporadic missing zoom image in fullscreen modal caused by missing dimensions and incorrect positioning. 53 | 54 | ## [2.0.2](https://github.com/laurenashpole/react-inner-image-zoom/compare/v2.0.1...v2.0.2) (2021-06-15) 55 | 56 | ### Fixed 57 | 58 | - Incorrect initial zoom position in fullscreen modal. 59 | - Persist the zoomed image after zoom out if `zoomPreload` is true. 60 | 61 | ## [2.0.1](https://github.com/laurenashpole/react-inner-image-zoom/compare/v2.0.0...v2.0.1) (2021-03-12) 62 | 63 | ### Fixed 64 | 65 | - Set the scaled image size based on `naturalWidth` and `naturalHeight` instead of `offsetWidth` and `offsetHeight`. 66 | 67 | ## [2.0.0](https://github.com/laurenashpole/react-inner-image-zoom/compare/v1.3.0...v2.0.0) (2021-03-03) 68 | 69 | ### Changed 70 | 71 | - Refactored using React hooks. All versions after 2.0.0 require React v16.8.0 or above. 72 | - Renamed `startsActive` to `zoomPreload` 73 | 74 | ### Added 75 | 76 | - `hideHint` prop to hide the magnifying glass icon. 77 | - `hideCloseButton` prop to hide the close button on touch devices. 78 | - `width`, `height`, and `hasSpacer` props to set the original image's width and height attributes and optionally generate a spacer based on those values to avoid cumulative layout shift. 79 | - CONTRIBUTING guide. 80 | - ESLint and Prettier formatting. 81 | 82 | ## [1.3.0](https://github.com/laurenashpole/react-inner-image-zoom/compare/v1.2.0...v1.3.0) (2020-11-24) 83 | 84 | ### Added 85 | 86 | - `zoomScale` prop to set the size of the zoomed image. 87 | - `startsActive` prop to load the zoomed image on render. 88 | 89 | ## [1.2.0](https://github.com/laurenashpole/react-inner-image-zoom/compare/v1.1.1...v1.2.0) (2020-11-21) 90 | 91 | ### Added 92 | 93 | - `zoomType` prop with "hover" option to trigger zoom on hover. 94 | 95 | ## [1.1.1](https://github.com/laurenashpole/react-inner-image-zoom/compare/v1.1.0...v1.1.1) (2020-07-13) 96 | 97 | ### Fixed 98 | 99 | - Removed unnecessary dragend events when image is not zoomed. 100 | 101 | ## [1.1.0](https://github.com/laurenashpole/react-inner-image-zoom/compare/v1.0.6...v1.1.0) (2020-07-12) 102 | 103 | ### Added 104 | 105 | - `moveType` prop with "drag" option for drag to move functionality on non-touch devices. 106 | 107 | ## [1.0.6](https://github.com/laurenashpole/react-inner-image-zoom/compare/v1.0.5...v1.0.6) (2020-05-22) 108 | 109 | ### Fixed 110 | 111 | - Hide original image on zoom to support transparent zoom images. 112 | 113 | ## [1.0.5](https://github.com/laurenashpole/react-inner-image-zoom/compare/v1.0.0...v1.0.5) (2019-10-15) 114 | 115 | ### Changed 116 | 117 | - Removed `styles.css` import from React component to allow for a greater variety of build approaches. 118 | 119 | ### Added 120 | 121 | - Minified CSS file `styles.min.css`. 122 | - "Styling" section in README file. 123 | 124 | ## [1.0.0](https://github.com/laurenashpole/react-inner-image-zoom/compare/e8e458231a32831a4332b4c009e7df2d68535ada...v1.0.0) (2019-06-19) 125 | 126 | ### Added 127 | 128 | - InnerImageZoom React component. 129 | - README and LICENSE. 130 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First, thanks for your interest in contributing to React Inner Image Zoom! I didn't expect the enthusiasm for it so that's been pretty cool to see. 4 | 5 | If you're looking for something to work on or want to talk through an idea before you start coding, visit the [issues page](https://github.com/laurenashpole/react-inner-image-zoom/issues). 6 | 7 | ## Getting Started 8 | 9 | This component was bootstrapped using [nwb](https://github.com/insin/nwb)'s `react-component` command to speed through setting up demos, testing, and the basic build process. 10 | 11 | Commits to this repo should follow the forking workflow. For an overview, check out this [tutorial](https://www.atlassian.com/git/tutorials/comparing-workflows/forking-workflow) and use their instructions for copying your personal repo. Once that's done, install your node modules with: 12 | 13 | ``` 14 | npm install 15 | ``` 16 | 17 | and then run: 18 | 19 | ``` 20 | npm start 21 | ``` 22 | 23 | to start your demo app at [http://localhost:3000](http://localhost:3000). 24 | 25 | ## Development 26 | 27 | The basic file structure in your new repo will be: 28 | 29 | - `demo` demo app files. 30 | - `src` component source files. 31 | - `tests` tests and testing data. 32 | 33 | Changes in the `src` directory will be reflected in the published package. When you've written your code and feel ready to commit, please use the [Angular Commit Message Conventions](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#-git-commit-guidelines) when writing your commit messages (feel free to just use `*` for scope). This package uses [Semantic Release](https://github.com/semantic-release/semantic-release) for releases and versioning so that helps keep everything up to date. 34 | 35 | If you're adding a new prop, don't forget to include a short description in the props table in the `README.md` file. 36 | 37 | ## Testing 38 | 39 | nwb comes with [Karma](https://github.com/karma-runner/karma) built-in so that's the test runner of choice here. Since accurately testing this component requires actually loading image files, the tests are written using the [ReactDOM testing utilities](https://reactjs.org/docs/test-utils.html). 40 | 41 | The following commands are available for testing: 42 | 43 | - `npm test` will run the tests once. 44 | - `npm run test:coverage` will run the tests and produce a coverage report in `coverage/`. 45 | - `npm run test:watch` will run the tests on every change. 46 | 47 | Each command will also run [ESLint](https://github.com/eslint/eslint) on the component source files. 48 | 49 | If you can, try to include new tests with your changes. Otherwise, just make sure to run `npm test` to check that the existing tests still pass before opening a pull request. 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Lauren Ashpole 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 | # MOVED 2 | 3 | This package has moved to the [inner-image-zoom](https://github.com/laurenashpole/inner-image-zoom) repo. For React specific docs, click [here](https://github.com/laurenashpole/inner-image-zoom/tree/main/packages/react) or check out the new demos site [here](https://innerimagezoom.com/). Issues and PRs have been left open for reference/consideration in future releases in that repo. 4 | 5 | --- 6 | 7 | # react-inner-image-zoom 8 | 9 | [Demos](https://laurenashpole.github.io/react-inner-image-zoom) 10 | 11 | ![GitHub Actions][build-badge] [![npm package][npm-badge]][npm] [![TypeScript definitions on DefinitelyTyped][dt-badge]][dt] 12 | 13 | A React component for magnifying an image within its original container. Zoom behavior can be triggered on click or hover and the zoomed image can be moved by dragging on touch devices and either dragging or pan on hover on non-touch devices. The component supports responsive images, loading placeholders, optional fullscreen zoom on mobile, and more. 14 | 15 | ## Installation 16 | 17 | **Note:** Version 2.0.0 introduces React hooks and requires React v16.8.0 or above. To use this package with older versions of React, install with `npm install react-inner-image-zoom@1.3.0` or `yarn add react-inner-image-zoom@1.3.0` instead of the instructions below. 18 | 19 | ### NPM 20 | ``` 21 | npm install react-inner-image-zoom 22 | ``` 23 | 24 | ### Yarn 25 | ``` 26 | yarn add react-inner-image-zoom 27 | ``` 28 | 29 | ### TypeScript 30 | 31 | For TypeScript users, type definitions are available through [DefinitelyTyped](https://definitelytyped.org/) and can be installed with: 32 | 33 | ``` 34 | npm install --save-dev @types/react-inner-image-zoom 35 | ``` 36 | 37 | ### Styling 38 | 39 | I was originally importing the CSS directly into the component but I've recently realized that makes too many assumptions about the wider build process. You can now download the raw CSS file at: 40 | 41 | [/src/InnerImageZoom/styles.css](https://raw.githubusercontent.com/laurenashpole/react-inner-image-zoom/master/src/InnerImageZoom/styles.css) 42 | 43 | or the minified raw minified version at: 44 | 45 | [/src/InnerImageZoom/styles.min.css](https://raw.githubusercontent.com/laurenashpole/react-inner-image-zoom/master/src/InnerImageZoom/styles.min.css) 46 | 47 | to include however you see fit. Or, if your setup supports it, import the files directory from your `node_modules` using: 48 | 49 | ```javascript 50 | import 'react-inner-image-zoom/lib/InnerImageZoom/styles.css'; 51 | ``` 52 | 53 | or: 54 | 55 | ```javascript 56 | import 'react-inner-image-zoom/lib/InnerImageZoom/styles.min.css'; 57 | ``` 58 | 59 | ## Usage 60 | 61 | Import and render the component: 62 | ```javascript 63 | import InnerImageZoom from 'react-inner-image-zoom'; 64 | 65 | ... 66 | 67 | 68 | ``` 69 | 70 | This is the simplest usage. For additional examples, visit the [demo page](https://laurenashpole.github.io/react-inner-image-zoom). 71 | 72 | 73 | ## Props 74 | 75 | Prop | Type | Default | Description 76 | --- | --- | --- | --- 77 | src | String | | (Required) URL for the original image. 78 | sources | Array | | A list of image sources for using the picture tag to serve the appropriate original image (see below for more details). 79 | width | Number | | Width attribute for original image. 80 | height | Number | | Height attribute for original image. 81 | hasSpacer | Boolean | false | If true, gets the original image's aspect ratio based on the width and height props and creates a spacer to prevent cumulative layout shift. 82 | imgAttributes | Object | | [Img](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attributes) and [global](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes) attributes for the original image (excluding `src`, `width`, `height`, and `style` which are set elsewhere). The imgAttributes keys should follow the [React DOM element](https://reactjs.org/docs/dom-elements.html) naming conventions. 83 | zoomSrc | String | | URL for the larger zoom image. Falls back to original image src if not defined. 84 | zoomScale | Number | 1 | Multiplied against the natural width and height of the zoomed image. This will generally be a decimal (example, 0.9 for 90%). 85 | zoomPreload | Boolean | false | If set to true, preloads the zoom image instead of waiting for mouseenter and (unless on a touch device) persists the image on mouseleave. 86 | moveType | String | pan | `pan` or `drag`. The user behavior for moving zoomed images on non-touch devices. 87 | zoomType | String | click | `click` or `hover`. The user behavior for triggering zoom. When using `hover`, combine with `zoomPreload` to avoid flickering on rapid mouse movements. 88 | fadeDuration | Number | 150 | Fade transition time in milliseconds. If zooming in on transparent images, set this to `0` for best results. 89 | fullscreenOnMobile | Boolean | false | Enables fullscreen zoomed image on touch devices below a specified breakpoint. 90 | mobileBreakpoint | Number | 640 | The maximum breakpoint for fullscreen zoom image when fullscreenOnMobile is true. 91 | hideCloseButton | Boolean | false | Hides the close button on touch devices. If set to true, zoom out is triggered by tap. 92 | hideHint | Boolean | false | Hides the magnifying glass hint. 93 | className | String | | Custom classname for styling the component. 94 | afterZoomIn | Function | | Function to be called after zoom in. 95 | afterZoomOut | Function | | Function to be called after zoom out. 96 | 97 | ### Sources 98 | 99 | This prop accepts an array of objects which it uses to create a picture tag and source elements. The component looks for the following optional properties and you can find additional details on responsive images [here](https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images): 100 | 101 | Prop | Type | Default | Description 102 | --- | --- | --- | --- 103 | srcSet | String | | Srcset attribute for source tag. 104 | sizes | String | | Sizes attribute for source tag. 105 | media | String | | An attribute containing a media condition for use with the srcset. 106 | type | String | | An image MIME type. This is useful for using newer formats like WebP. 107 | 108 | ## Issues 109 | 110 | Please submit issues or requests [here](https://github.com/laurenashpole/react-inner-image-zoom/issues). 111 | 112 | Most of the implementation choices for this component are based on use cases I've encountered in the past. For example, I chose click to zoom as the default because it's been the most requested on product detail pages I've worked on. If there's a demand for additional triggers or other functionality, I'd be open to looking into it so feel free to ask. And if you want to talk through ideas first, check out the [discussions page](https://github.com/laurenashpole/react-inner-image-zoom/discussions). 113 | 114 | If you're interested in contributing, check out the guidelines [here](https://github.com/laurenashpole/react-inner-image-zoom/blob/master/CONTRIBUTING.md). 115 | 116 | ## License 117 | 118 | [MIT](https://github.com/laurenashpole/react-inner-image-zoom/blob/master/LICENSE) 119 | 120 | [build-badge]: https://github.com/laurenashpole/react-inner-image-zoom/actions/workflows/release.yml/badge.svg 121 | 122 | [npm-badge]: http://img.shields.io/npm/v/react-inner-image-zoom.svg?style=flat 123 | [npm]: https://www.npmjs.com/package/react-inner-image-zoom 124 | 125 | [dt-badge]: https://definitelytyped.org/badges/standard-flat.svg 126 | [dt]: http://definitelytyped.org 127 | -------------------------------------------------------------------------------- /demo/public/unsplash-large.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenashpole/react-inner-image-zoom/fed33fbd40236757cd5a24948493d44dcc35fcc2/demo/public/unsplash-large.jpg -------------------------------------------------------------------------------- /demo/public/unsplash.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenashpole/react-inner-image-zoom/fed33fbd40236757cd5a24948493d44dcc35fcc2/demo/public/unsplash.jpg -------------------------------------------------------------------------------- /demo/public/unsplash2-large.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenashpole/react-inner-image-zoom/fed33fbd40236757cd5a24948493d44dcc35fcc2/demo/public/unsplash2-large.jpg -------------------------------------------------------------------------------- /demo/public/unsplash2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenashpole/react-inner-image-zoom/fed33fbd40236757cd5a24948493d44dcc35fcc2/demo/public/unsplash2.jpg -------------------------------------------------------------------------------- /demo/public/unsplash3-large.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenashpole/react-inner-image-zoom/fed33fbd40236757cd5a24948493d44dcc35fcc2/demo/public/unsplash3-large.jpg -------------------------------------------------------------------------------- /demo/public/unsplash3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/laurenashpole/react-inner-image-zoom/fed33fbd40236757cd5a24948493d44dcc35fcc2/demo/public/unsplash3.jpg -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { render } from 'react-dom'; 3 | import InnerImageZoom from '../../src'; 4 | import '../../src/InnerImageZoom/styles.css'; 5 | 6 | class Demo extends Component { 7 | render() { 8 | return ( 9 |
10 |

react-inner-image-zoom Demo

11 |
12 |

Pan Example

13 | console.log('Original image loaded') 24 | }} 25 | /> 26 |
27 |
28 |

Hover Example

29 | 39 |
40 |
41 |

Drag Example

42 | 50 |
51 |
52 | ); 53 | } 54 | } 55 | 56 | render(, document.querySelector('#demo')); 57 | -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'react-component', 3 | npm: { 4 | esModules: true, 5 | umd: false 6 | }, 7 | karma: { 8 | browsers: ['ChromeHeadless'], 9 | plugins: ['karma-firefox-launcher'] 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-inner-image-zoom", 3 | "version": "3.0.2", 4 | "description": "A React component for magnifying an image within its original container.", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "files": [ 8 | "css", 9 | "es", 10 | "lib", 11 | "umd" 12 | ], 13 | "scripts": { 14 | "build": "nwb build-react-component --copy-files", 15 | "clean": "nwb clean-module && nwb clean-demo", 16 | "prepublishOnly": "npm run build", 17 | "start": "nwb serve-react-demo", 18 | "test": "nwb test-react --karma.browsers=FirefoxHeadless --karma.browsers=ChromeHeadless && eslint .", 19 | "test:coverage": "nwb test-react --coverage", 20 | "test:watch": "nwb test-react --server", 21 | "lint": "eslint --fix .", 22 | "semantic-release": "semantic-release" 23 | }, 24 | "dependencies": {}, 25 | "peerDependencies": { 26 | "react": ">=16.8.0", 27 | "prop-types": ">=15.6.2" 28 | }, 29 | "devDependencies": { 30 | "eslint": "^7.18.0", 31 | "eslint-config-prettier": "^7.2.0", 32 | "eslint-plugin-prettier": "^3.3.1", 33 | "eslint-plugin-react": "^7.22.0", 34 | "eslint-plugin-react-hooks": "^4.2.0", 35 | "karma-firefox-launcher": "^2.1.1", 36 | "nwb": "^0.25.2", 37 | "prettier": "^2.2.1", 38 | "react": "^16.14.0", 39 | "react-dom": "^16.14.0", 40 | "semantic-release": "^19.0.3" 41 | }, 42 | "author": "Lauren Ashpole", 43 | "homepage": "https://github.com/laurenashpole/react-inner-image-zoom#readme", 44 | "license": "MIT", 45 | "repository": { 46 | "type": "git", 47 | "url": "https://github.com/laurenashpole/react-inner-image-zoom.git" 48 | }, 49 | "bugs": { 50 | "url": "https://github.com/laurenashpole/react-inner-image-zoom/issues" 51 | }, 52 | "keywords": [ 53 | "react", 54 | "react-component", 55 | "magnify", 56 | "zoom", 57 | "enlarge", 58 | "image", 59 | "responsive", 60 | "photo", 61 | "pdp", 62 | "ecommerce" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /src/InnerImageZoom/InnerImageZoom.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Image from './components/Image'; 4 | import ZoomImage from './components/ZoomImage'; 5 | import FullscreenPortal from './components/FullscreenPortal'; 6 | 7 | const InnerImageZoom = ({ 8 | moveType = 'pan', 9 | zoomType = 'click', 10 | src, 11 | sources, 12 | width, 13 | height, 14 | hasSpacer, 15 | imgAttributes = {}, 16 | zoomSrc, 17 | zoomScale = 1, 18 | zoomPreload, 19 | fadeDuration = 150, 20 | fullscreenOnMobile, 21 | mobileBreakpoint = 640, 22 | hideCloseButton, 23 | hideHint, 24 | className, 25 | afterZoomIn, 26 | afterZoomOut 27 | }) => { 28 | const img = useRef(null); 29 | const zoomImg = useRef(null); 30 | const imgProps = useRef({}); 31 | const [isActive, setIsActive] = useState(zoomPreload); 32 | const [isTouch, setIsTouch] = useState(false); 33 | const [isZoomed, setIsZoomed] = useState(false); 34 | const [isFullscreen, setIsFullscreen] = useState(false); 35 | const [isDragging, setIsDragging] = useState(false); 36 | const [isValidDrag, setIsValidDrag] = useState(false); 37 | const [isFading, setIsFading] = useState(false); 38 | const [currentMoveType, setCurrentMoveType] = useState(moveType); 39 | const [left, setLeft] = useState(0); 40 | const [top, setTop] = useState(0); 41 | 42 | const handleMouseEnter = (e) => { 43 | setIsActive(true); 44 | setIsFading(false); 45 | zoomType === 'hover' && !isZoomed && handleClick(e); 46 | }; 47 | 48 | const handleTouchStart = () => { 49 | setIsTouch(true); 50 | setIsFullscreen(getFullscreenStatus(fullscreenOnMobile, mobileBreakpoint)); 51 | setCurrentMoveType('drag'); 52 | }; 53 | 54 | const handleClick = (e) => { 55 | if (isZoomed) { 56 | if (isTouch) { 57 | hideCloseButton && handleClose(e); 58 | } else { 59 | !isValidDrag && zoomOut(); 60 | } 61 | 62 | return; 63 | } 64 | 65 | isTouch && setIsActive(true); 66 | 67 | if (zoomImg.current) { 68 | handleLoad({ target: zoomImg.current }); 69 | zoomIn(e.pageX, e.pageY); 70 | } else { 71 | imgProps.current.onLoadCallback = zoomIn.bind(this, e.pageX, e.pageY); 72 | } 73 | }; 74 | 75 | const handleLoad = (e) => { 76 | const scaledDimensions = getScaledDimensions(e.target, zoomScale); 77 | 78 | zoomImg.current = e.target; 79 | zoomImg.current.setAttribute('width', scaledDimensions.width); 80 | zoomImg.current.setAttribute('height', scaledDimensions.height); 81 | 82 | imgProps.current.scaledDimensions = scaledDimensions; 83 | imgProps.current.bounds = getBounds(img.current, false); 84 | imgProps.current.ratios = getRatios(imgProps.current.bounds, scaledDimensions); 85 | 86 | if (imgProps.current.onLoadCallback) { 87 | imgProps.current.onLoadCallback(); 88 | imgProps.current.onLoadCallback = null; 89 | } 90 | }; 91 | 92 | const handleMouseMove = (e) => { 93 | let left = e.pageX - imgProps.current.offsets.x; 94 | let top = e.pageY - imgProps.current.offsets.y; 95 | 96 | left = Math.max(Math.min(left, imgProps.current.bounds.width), 0); 97 | top = Math.max(Math.min(top, imgProps.current.bounds.height), 0); 98 | 99 | setLeft(left * -imgProps.current.ratios.x); 100 | setTop(top * -imgProps.current.ratios.y); 101 | }; 102 | 103 | const handleDragStart = (e) => { 104 | const pageX = typeof e.pageX === 'number' ? e.pageX : e.changedTouches[0].pageX; 105 | const pageY = typeof e.pageY === 'number' ? e.pageY : e.changedTouches[0].pageY; 106 | imgProps.current.offsets = getOffsets(pageX, pageY, zoomImg.current.offsetLeft, zoomImg.current.offsetTop); 107 | 108 | setIsDragging(true); 109 | 110 | if (!isTouch) { 111 | imgProps.current.eventPosition = { 112 | x: e.pageX, 113 | y: e.pageY 114 | }; 115 | } 116 | }; 117 | 118 | const handleDragMove = useCallback((e) => { 119 | e.stopPropagation(); 120 | const pageX = typeof e.pageX === 'number' ? e.pageX : e.changedTouches[0].pageX; 121 | const pageY = typeof e.pageY === 'number' ? e.pageY : e.changedTouches[0].pageY; 122 | let left = pageX - imgProps.current.offsets.x; 123 | let top = pageY - imgProps.current.offsets.y; 124 | 125 | left = Math.max(Math.min(left, 0), (imgProps.current.scaledDimensions.width - imgProps.current.bounds.width) * -1); 126 | top = Math.max(Math.min(top, 0), (imgProps.current.scaledDimensions.height - imgProps.current.bounds.height) * -1); 127 | 128 | setLeft(left); 129 | setTop(top); 130 | }, []); 131 | 132 | const handleDragEnd = (e) => { 133 | setIsDragging(false); 134 | 135 | if (!isTouch) { 136 | const moveX = Math.abs(e.pageX - imgProps.current.eventPosition.x); 137 | const moveY = Math.abs(e.pageY - imgProps.current.eventPosition.y); 138 | setIsValidDrag(moveX > 5 || moveY > 5); 139 | } 140 | }; 141 | 142 | const handleMouseLeave = (e) => { 143 | currentMoveType === 'drag' && isZoomed ? handleDragEnd(e) : handleClose(e); 144 | }; 145 | 146 | const handleClose = (e) => { 147 | if (!(!isTouch && e.target.classList.contains('iiz__close'))) { 148 | if (!isZoomed || isFullscreen || !fadeDuration) { 149 | handleFadeOut({}, true); 150 | } else { 151 | setIsFading(true); 152 | } 153 | } 154 | 155 | zoomOut(); 156 | }; 157 | 158 | const handleFadeOut = (e, noTransition) => { 159 | if (noTransition || (e.propertyName === 'opacity' && img.current.contains(e.target))) { 160 | if ((zoomPreload && isTouch) || !zoomPreload) { 161 | zoomImg.current = null; 162 | imgProps.current = getDefaults(); 163 | setIsActive(false); 164 | } 165 | 166 | setIsTouch(false); 167 | setIsFullscreen(false); 168 | setCurrentMoveType(moveType); 169 | setIsFading(false); 170 | } 171 | }; 172 | 173 | const initialMove = (pageX, pageY) => { 174 | imgProps.current.offsets = getOffsets( 175 | window.pageXOffset, 176 | window.pageYOffset, 177 | -imgProps.current.bounds.left, 178 | -imgProps.current.bounds.top 179 | ); 180 | handleMouseMove({ pageX, pageY }); 181 | }; 182 | 183 | const initialDrag = (pageX, pageY) => { 184 | let initialPageX = (pageX - (window.pageXOffset + imgProps.current.bounds.left)) * -imgProps.current.ratios.x; 185 | let initialPageY = (pageY - (window.pageYOffset + imgProps.current.bounds.top)) * -imgProps.current.ratios.y; 186 | 187 | initialPageX = initialPageX + (isFullscreen ? (window.innerWidth - imgProps.current.bounds.width) / 2 : 0); 188 | initialPageY = initialPageY + (isFullscreen ? (window.innerHeight - imgProps.current.bounds.height) / 2 : 0); 189 | imgProps.current.bounds = getBounds(img.current, isFullscreen); 190 | imgProps.current.offsets = getOffsets(0, 0, 0, 0); 191 | 192 | handleDragMove({ 193 | changedTouches: [ 194 | { 195 | pageX: initialPageX, 196 | pageY: initialPageY 197 | } 198 | ], 199 | preventDefault: () => {}, 200 | stopPropagation: () => {} 201 | }); 202 | }; 203 | 204 | const zoomIn = (pageX, pageY) => { 205 | setIsZoomed(true); 206 | currentMoveType === 'drag' ? initialDrag(pageX, pageY) : initialMove(pageX, pageY); 207 | afterZoomIn && afterZoomIn(); 208 | }; 209 | 210 | const zoomOut = () => { 211 | setIsZoomed(false); 212 | afterZoomOut && afterZoomOut(); 213 | }; 214 | 215 | const getDefaults = () => { 216 | return { 217 | onLoadCallback: null, 218 | bounds: {}, 219 | offsets: {}, 220 | ratios: {}, 221 | eventPosition: {}, 222 | scaledDimensions: {} 223 | }; 224 | }; 225 | 226 | const getBounds = (img, isFullscreen) => { 227 | if (isFullscreen) { 228 | return { 229 | width: window.innerWidth, 230 | height: window.innerHeight, 231 | left: 0, 232 | top: 0 233 | }; 234 | } 235 | 236 | return img.getBoundingClientRect(); 237 | }; 238 | 239 | const getOffsets = (pageX, pageY, left, top) => { 240 | return { 241 | x: pageX - left, 242 | y: pageY - top 243 | }; 244 | }; 245 | 246 | const getRatios = (bounds, dimensions) => { 247 | return { 248 | x: (dimensions.width - bounds.width) / bounds.width, 249 | y: (dimensions.height - bounds.height) / bounds.height 250 | }; 251 | }; 252 | 253 | const getFullscreenStatus = (fullscreenOnMobile, mobileBreakpoint) => { 254 | return fullscreenOnMobile && window.matchMedia && window.matchMedia(`(max-width: ${mobileBreakpoint}px)`).matches; 255 | }; 256 | 257 | const getScaledDimensions = (zoomImg, zoomScale) => { 258 | return { 259 | width: zoomImg.naturalWidth * zoomScale, 260 | height: zoomImg.naturalHeight * zoomScale 261 | }; 262 | }; 263 | 264 | const zoomImageProps = { 265 | src: zoomSrc || src, 266 | fadeDuration: isFullscreen ? 0 : fadeDuration, 267 | top, 268 | left, 269 | isZoomed, 270 | onLoad: handleLoad, 271 | onDragStart: currentMoveType === 'drag' ? handleDragStart : null, 272 | onDragEnd: currentMoveType === 'drag' ? handleDragEnd : null, 273 | onClose: !hideCloseButton && currentMoveType === 'drag' ? handleClose : null, 274 | onFadeOut: isFading ? handleFadeOut : null 275 | }; 276 | 277 | useEffect(() => { 278 | imgProps.current = getDefaults(); 279 | }, []); 280 | 281 | useEffect(() => { 282 | getFullscreenStatus(fullscreenOnMobile, mobileBreakpoint) && setIsActive(false); 283 | }, [fullscreenOnMobile, mobileBreakpoint]); 284 | 285 | useEffect(() => { 286 | if (!zoomImg.current) { 287 | return; 288 | } 289 | 290 | const eventType = isTouch ? 'touchmove' : 'mousemove'; 291 | 292 | if (isDragging) { 293 | zoomImg.current.addEventListener(eventType, handleDragMove, { passive: true }); 294 | } else { 295 | zoomImg.current.removeEventListener(eventType, handleDragMove); 296 | } 297 | }, [isDragging, isTouch, handleDragMove]); 298 | 299 | return ( 300 |
310 | 320 | 321 | {isActive && ( 322 | 323 | {isFullscreen ? ( 324 | 325 | 326 | 327 | ) : ( 328 | 329 | )} 330 | 331 | )} 332 | 333 | {!hideHint && !isZoomed && } 334 |
335 | ); 336 | }; 337 | 338 | InnerImageZoom.propTypes = { 339 | moveType: PropTypes.string, 340 | zoomType: PropTypes.string, 341 | src: PropTypes.string.isRequired, 342 | sources: PropTypes.array, 343 | width: PropTypes.number, 344 | height: PropTypes.number, 345 | hasSpacer: PropTypes.bool, 346 | imgAttributes: PropTypes.object, 347 | zoomSrc: PropTypes.string, 348 | zoomScale: PropTypes.number, 349 | zoomPreload: PropTypes.bool, 350 | fadeDuration: PropTypes.number, 351 | fullscreenOnMobile: PropTypes.bool, 352 | mobileBreakpoint: PropTypes.number, 353 | hideCloseButton: PropTypes.bool, 354 | hideHint: PropTypes.bool, 355 | className: PropTypes.string, 356 | afterZoomIn: PropTypes.func, 357 | afterZoomOut: PropTypes.func 358 | }; 359 | 360 | export default InnerImageZoom; 361 | -------------------------------------------------------------------------------- /src/InnerImageZoom/components/FullscreenPortal.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | import PropTypes from 'prop-types'; 4 | 5 | const FullscreenPortal = ({ children }) => { 6 | const [portal] = useState(() => { 7 | const el = document.createElement('div'); 8 | el.classList.add('iiz__zoom-portal'); 9 | return el; 10 | }); 11 | 12 | useEffect(() => { 13 | document.body.appendChild(portal); 14 | return () => document.body.removeChild(portal); 15 | }, [portal]); 16 | 17 | return createPortal(children, portal); 18 | }; 19 | 20 | FullscreenPortal.propTypes = { 21 | children: PropTypes.element 22 | }; 23 | 24 | export default FullscreenPortal; 25 | -------------------------------------------------------------------------------- /src/InnerImageZoom/components/Image.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Image = ({ src, sources, width, height, hasSpacer, imgAttributes, isZoomed, fadeDuration }) => { 5 | const createSpacer = width && height && hasSpacer; 6 | 7 | return ( 8 |
9 | {sources && sources.length > 0 ? ( 10 | 11 | {sources.map((source, i) => { 12 | return {source.srcSet && }; 13 | })} 14 | 15 | 29 | 30 | ) : ( 31 | 45 | )} 46 |
47 | ); 48 | }; 49 | 50 | Image.propTypes = { 51 | src: PropTypes.string.isRequired, 52 | sources: PropTypes.array, 53 | width: PropTypes.number, 54 | height: PropTypes.number, 55 | hasSpacer: PropTypes.bool, 56 | imgAttributes: PropTypes.object, 57 | fadeDuration: PropTypes.number, 58 | isZoomed: PropTypes.bool 59 | }; 60 | 61 | export default Image; 62 | -------------------------------------------------------------------------------- /src/InnerImageZoom/components/ZoomImage.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const ZoomImage = ({ src, fadeDuration, top, left, isZoomed, onLoad, onDragStart, onDragEnd, onClose, onFadeOut }) => { 5 | return ( 6 | 7 | 24 | 25 | {onClose && ( 26 |