├── .gitignore ├── LICENSE ├── README.md ├── dist ├── ImageZoom.d.ts ├── MagnifyingGlass.d.ts ├── ReactSimpleImageZoom.js ├── ZoomContainer.d.ts └── index.d.ts ├── docs ├── assets │ ├── cat.jpg │ └── react-simple-image-zoom-example.png ├── dist │ ├── bundle.js │ └── bundle.js.map └── index.html ├── example ├── assets │ └── cat.jpg ├── index.html ├── src │ └── App.tsx └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── ImageZoom.tsx ├── MagnifyingGlass.tsx ├── ZoomContainer.tsx ├── experimental │ └── scrollZoom.ts └── index.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn-error.log 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Aaron Lifton 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 | ## React simple image zoom 2 | A simple image zoom component 3 | 4 | ### Demo 5 | - [https://aaronlifton.github.io/react-simple-image-zoom/](https://aaronlifton.github.io/react-simple-image-zoom/) 6 | 7 | 8 | ![Example](https://github.com/aaronlifton/react-simple-image-zoom/blob/master/docs/assets/react-simple-image-zoom-example.png?raw=true) 9 | 10 | [![npm version](https://badge.fury.io/js/react-simple-image-zoom.svg)](https://badge.fury.io/js/react-simple-image-zoom) 11 | 12 | ### Install 13 | | mgr | cmd | 14 | |--|---| 15 | |npm|`npm install --save react-simple-image-zoom`| 16 | |yarn|`yarn add react-simple-image-zoom`| 17 | 18 | ### Usage 19 | ```tsx 20 | import { ImageZoom } from 'react-simple-image-zoom'; 21 | const largeCatImg = 'https://www.nationalgeographic.com/content/dam/animals/thumbs/rights-exempt/mammals/d/domestic-cat_thumb.ngsversion.1472140774957.adapt.1900.1.jpg'; 22 | 23 | const App = () => 24 |
25 |
26 | 40 | Cat image 41 | 42 |
43 | 44 |
45 |
46 | 47 | ReactDOM.render(, document.getElementById('myAppContainer')); 48 | ``` 49 | 50 | See `./demo` for a more detailed example. 51 | 52 | ### Props 53 | 54 | | prop | required | type | description | 55 | | ------------- |----------|--------|-----| 56 | | children     |yes| any | pass the source image in as a child element | 57 | | portalId |yes| string | ID of the target portal element | 58 | | largeImgSrc |no| string | optional high-res source to use for the zoom container | 59 | | imageWidth |yes| number | width of the original image on the screen | 60 | | imageHeight |no| number | optional, pass in an image height to use for calculations. otherwise this component will figure it out.| 61 | | zoomContainerWidth |yes| number | width of the portal zoom | 62 | | zoomContainerHeight |no| number | height of the portal zoom | 63 | | activeClass |no| string | optional, default is 'active'. applies this class to the image container when zooming is active | 64 | | portalStyle |no| React.CSSProperties | optional, override the style of the portal. To extend the default style, use `ImageZoom.defaultPortalStyle` | 65 | | portalClassName |no| string | optional, sets className on the portal element | 66 | | zoomScale |no| number | optional, default is 1. Determines the amount of zoom. | 67 | | responsive |no| boolean | optional, default is null. Component will listen for window resize and adjust accordingly| 68 | 69 | 70 | ### Usage with react-slick 71 | - For the magnifying glass to work, make sure you style `.slick-side` like this: 72 | ```css 73 | .slick-side { 74 | position: relative; 75 | } 76 | ``` 77 | 78 | ### Development 79 | - `yarn run dev` 80 | - check `http://localhost:8080/docs` 81 | ### Todo 82 | - get component to work on mobile devices 83 | 84 | 85 | ### License 86 | Copyright © 2018 Aaron Lifton 87 | -------------------------------------------------------------------------------- /dist/ImageZoom.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import * as React from 'react'; 3 | export interface ImageZoomProps { 4 | children: any; 5 | portalId: string; 6 | largeImgSrc?: string; 7 | imageWidth: number; 8 | imageHeight?: number; 9 | zoomContainerWidth: number; 10 | zoomContainerHeight?: number; 11 | activeClass?: string; 12 | portalStyle?: React.CSSProperties; 13 | portalClassName?: string; 14 | zoomScale?: number; 15 | responsive?: boolean; 16 | } 17 | export interface ImageZoomState { 18 | zoomX: number; 19 | zoomY: number; 20 | portalEl?: HTMLElement; 21 | isActive: boolean; 22 | glassX: number; 23 | glassY: number; 24 | glassWidth: number; 25 | glassHeight: number; 26 | zoomImageWidth?: number; 27 | zoomImageHeight?: number; 28 | scaleX: number; 29 | scaleY: number; 30 | offset?: any; 31 | offsetX: number; 32 | offsetY: number; 33 | lastScrollXPos?: number; 34 | lastScrollYPos?: number; 35 | zoomScale: number; 36 | imageWidth: number; 37 | imageHeight?: number; 38 | zoomContainerWidth: number; 39 | } 40 | export default class ImageZoom extends React.Component { 41 | zoomContainer: HTMLElement; 42 | image: HTMLElement; 43 | zoomImage: HTMLImageElement; 44 | imgSrc: string; 45 | portalStyle: React.CSSProperties; 46 | toggle: () => void; 47 | deactivate: () => void; 48 | onResize: () => void; 49 | static defaultPortalStyle: React.CSSProperties; 50 | constructor(props: any); 51 | getOffset(el: HTMLElement): { 52 | left: number; 53 | top: number; 54 | }; 55 | calcScaleX(width: number, zoomScale?: number): number; 56 | calcScaleY(height: number, zoomScale?: number): number; 57 | calcZoomImageWidth(zoomScale?: number): number; 58 | calcZoomImageHeight(zoomScale?: number): number; 59 | getPortalStyle(): React.CSSProperties; 60 | onWindowResize(): void; 61 | componentWillUnmount(): void; 62 | componentDidMount(): void; 63 | componentWillReceiveProps(newProps: ImageZoomProps): any; 64 | getGlassPos(evt: MouseEvent): { 65 | glassX: any; 66 | glassY: any; 67 | }; 68 | zoom(evt: React.SyntheticEvent): void; 69 | getPosition(v: any, min: any, max: any): number; 70 | getZoomStateFromEvent(evt: MouseEvent): { 71 | x: number; 72 | y: number; 73 | }; 74 | getStateFromEvent(synthEvt: React.SyntheticEvent): { 75 | glassX: any; 76 | glassY: any; 77 | zoomX: number; 78 | zoomY: number; 79 | }; 80 | toggleActive(evt: React.SyntheticEvent): void; 81 | setInactive(): void; 82 | render(): JSX.Element; 83 | } 84 | -------------------------------------------------------------------------------- /dist/MagnifyingGlass.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | export interface MagnifyingGlassProps { 3 | x: number; 4 | y: number; 5 | width: number; 6 | height: number; 7 | } 8 | declare const MagnifyingGlass: (props: MagnifyingGlassProps) => JSX.Element; 9 | export default MagnifyingGlass; 10 | -------------------------------------------------------------------------------- /dist/ReactSimpleImageZoom.js: -------------------------------------------------------------------------------- 1 | !function(t,e){if("object"==typeof exports&&"object"==typeof module)module.exports=e(require("react"),require("react-dom"));else if("function"==typeof define&&define.amd)define(["react","react-dom"],e);else{var s="object"==typeof exports?e(require("react"),require("react-dom")):e(t.React,t.ReactDOM);for(var i in s)("object"==typeof exports?exports:t)[i]=s[i]}}(window,function(t,e){return function(t){var e={};function s(i){if(e[i])return e[i].exports;var o=e[i]={i:i,l:!1,exports:{}};return t[i].call(o.exports,o,o.exports,s),o.l=!0,o.exports}return s.m=t,s.c=e,s.d=function(t,e,i){s.o(t,e)||Object.defineProperty(t,e,{configurable:!1,enumerable:!0,get:i})},s.r=function(t){Object.defineProperty(t,"__esModule",{value:!0})},s.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return s.d(e,"a",e),e},s.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},s.p="",s(s.s=5)}([function(e,s){e.exports=t},function(t,e,s){"use strict";Object.defineProperty(e,"__esModule",{value:!0});const i=s(0);e.default=(t=>i.createElement("div",{className:"glass",style:{pointerEvents:"none",position:"absolute",top:`${t.y}px`,left:`${t.x}px`,background:"#eee",width:`${t.width}px`,height:`${t.height}px`,backgroundColor:"rgba(0,0,0,.2)",zIndex:2}}))},function(t,e,s){"use strict";Object.defineProperty(e,"__esModule",{value:!0});const i=s(0);e.default=(t=>i.createElement("div",{className:"zoom-container",style:{width:`${t.zoomContainerWidth}px`,height:`${t.zoomContainerHeight}px`,backgroundImage:`url("${t.imgSrc}")`,backgroundTop:`${t.offsetX}`,backgroundLeft:`${t.offsetY}`,backgroundPosition:`-${t.zoomX}px -${t.zoomY}px`,backgroundSize:`${t.zoomImageWidth}px`,backgroundRepeat:"no-repeat"}}))},function(t,s){t.exports=e},function(t,e,s){"use strict";Object.defineProperty(e,"__esModule",{value:!0});const i=s(0),o=s(3),a=s(2),h=s(1);class n extends i.Component{constructor(t){super(t),this.state={isActive:!1,portalEl:null,zoomX:0,zoomY:0,glassX:0,glassY:0,glassWidth:40,glassHeight:40,scaleX:1,scaleY:1,offsetX:0,offsetY:0,zoomScale:this.props.zoomScale||1,imageWidth:this.props.imageWidth,imageHeight:this.props.imageHeight,zoomContainerWidth:this.props.zoomContainerWidth},this.portalStyle=this.props.portalStyle?this.props.portalStyle:n.defaultPortalStyle,this.onResize=this.onWindowResize.bind(this),this.toggle=this.toggleActive.bind(this),this.deactivate=this.setInactive.bind(this),this.zoom=this.zoom.bind(this)}getOffset(t){if(t){const e=t.getBoundingClientRect();return{left:e.left,top:e.top}}return{left:0,top:0}}calcScaleX(t,e=this.state.zoomScale){return this.zoomImage.naturalWidth*e/t}calcScaleY(t,e=this.state.zoomScale){return this.zoomImage.naturalHeight*e/(t||this.zoomImage.height)}calcZoomImageWidth(t=this.state.zoomScale){return this.zoomImage.naturalWidth*t}calcZoomImageHeight(t=this.state.zoomScale){return this.zoomImage.naturalHeight*t}getPortalStyle(){return this.props.responsive?Object.assign(Object.assign({},this.portalStyle),{width:`${this.state.zoomContainerWidth}px`}):this.portalStyle}onWindowResize(){const t=this.image.offsetWidth,e=this.image.offsetHeight,s=this.calcScaleX(t),i=this.calcScaleY(e),o=this.getOffset(this.image);this.setState({offset:o,scaleX:s,scaleY:i,zoomContainerWidth:t,glassWidth:t/s,glassHeight:e/i,imageWidth:t,imageHeight:e})}componentWillUnmount(){this.props.responsive&&window.removeEventListener("resize",this.onResize)}componentDidMount(){this.props.responsive&&window.addEventListener("resize",this.onResize);const t=new Image;t.src=this.props.children.props.src,t.onload=(()=>{let e,s;this.zoomImage=t,this.props.responsive&&(e=this.image.offsetWidth),s=this.image.offsetHeight;const i=this.calcScaleX(e||this.state.imageWidth),o=this.calcScaleY(s||this.state.imageHeight),a={offset:this.getOffset(this.image),scaleX:i,scaleY:o,zoomImageWidth:this.calcZoomImageWidth(),zoomImageHeight:this.calcZoomImageHeight(),glassWidth:this.props.zoomContainerWidth/i,glassHeight:(s||this.state.imageHeight)/o};e&&(a.imageWidth=e),s&&(a.imageHeight=s),this.setState(a)}),this.props.largeImgSrc?this.imgSrc=this.props.largeImgSrc:this.imgSrc=this.props.children&&this.props.children.props.src;const e=document.getElementById(this.props.portalId);this.setState({portalEl:e})}componentWillReceiveProps(t){if(!this.zoomImage)return null;const e=this.image.offsetWidth/this.zoomImage.naturalWidth;if(t.zoomScalethis.image.offsetWidth&&(o=this.image.offsetWidth),athis.image.offsetWidth-this.state.glassWidth&&(o=this.image.offsetWidth-this.state.glassWidth),o<0&&(o=0),a>this.image.offsetHeight-this.state.glassHeight&&(a=this.image.offsetHeight-this.state.glassHeight),a<0&&(a=0),{glassX:o,glassY:a}}zoom(t){if(!this.imgSrc||!this.state.isActive)return null;const e=this.getStateFromEvent(t);this.setState(e)}getPosition(t,e,s){let i;return ts&&(i=s),i||(i=t),i-e}getZoomStateFromEvent(t){let e,s,i=t.clientX-this.state.offset.left;i+=window.pageXOffset;const o=this.state.glassWidth/2,a=this.state.imageWidth-o,h=this.getPosition(i,o,a);let n=t.clientY-this.state.offset.top;n+=window.pageYOffset;const r=this.state.glassHeight/2,l=this.state.imageHeight-r,c=this.getPosition(n,r,l);return e=h*this.state.scaleX,(s=c*this.state.scaleY)<0&&(s=0),e<0&&(e=0),{x:e,y:s}}getStateFromEvent(t){const e=t.nativeEvent,s=this.getGlassPos(e),i=this.getZoomStateFromEvent(e);return Object.assign({zoomX:i.x,zoomY:i.y},s)}toggleActive(t){if(this.state.isActive)this.setState({isActive:!1});else{t.nativeEvent;const e=this.getStateFromEvent(t);this.setState(Object.assign({},e,{isActive:!0}))}}setInactive(){this.setState({isActive:!1})}render(){if(!this.state.portalEl)return console.log("no portalEl"),null;const t="image-zoom-container"+(this.state.isActive?" "+(this.props.activeClass||"active"):"");return i.createElement(i.Fragment,null,this.state.isActive&&this.state.zoomImageWidth&&o.createPortal(i.createElement("div",{ref:t=>this.zoomContainer=t,style:this.portalStyle,className:this.props.portalClassName},i.createElement(a.default,{imgSrc:this.imgSrc,offsetX:this.state.offsetX,offsetY:this.state.offsetY,zoomX:this.state.zoomX,zoomY:this.state.zoomY,zoomImageWidth:this.state.zoomImageWidth,zoomContainerWidth:this.state.zoomContainerWidth,zoomContainerHeight:this.props.zoomContainerHeight||this.state.imageHeight})),this.state.portalEl),i.createElement("div",{ref:t=>this.image=t,onClick:this.toggle,onMouseLeave:this.deactivate,onMouseMove:this.zoom,className:t},this.state.isActive&&this.state.offset&&i.createElement(h.default,{x:this.state.glassX+this.state.offset.left,y:this.state.glassY+this.state.offset.top,width:this.state.glassWidth,height:this.state.glassHeight}),this.props.children))}}n.defaultPortalStyle={position:"absolute",width:"540px",zIndex:1},e.default=n},function(t,e,s){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var i=s(4);e.ImageZoom=i.default}])}); -------------------------------------------------------------------------------- /dist/ZoomContainer.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { ImageZoomState } from './ImageZoom'; 3 | declare const ZoomContainer: (props: Partial & { 4 | zoomContainerHeight: number; 5 | imgSrc: string; 6 | }) => JSX.Element; 7 | export default ZoomContainer; 8 | -------------------------------------------------------------------------------- /dist/index.d.ts: -------------------------------------------------------------------------------- 1 | export { default as ImageZoom } from './ImageZoom'; 2 | -------------------------------------------------------------------------------- /docs/assets/cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronlifton/react-simple-image-zoom/46b871148a0e3e3b9ca142072601bc8e2bc176cd/docs/assets/cat.jpg -------------------------------------------------------------------------------- /docs/assets/react-simple-image-zoom-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronlifton/react-simple-image-zoom/46b871148a0e3e3b9ca142072601bc8e2bc176cd/docs/assets/react-simple-image-zoom-example.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Simple Image Zoom 7 | 56 | 57 | 58 |
59 | 60 | 61 | -------------------------------------------------------------------------------- /example/assets/cat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aaronlifton/react-simple-image-zoom/46b871148a0e3e3b9ca142072601bc8e2bc176cd/example/assets/cat.jpg -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Simple Image Zoom 7 | 56 | 57 | 58 |
59 | 60 | 61 | -------------------------------------------------------------------------------- /example/src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | import { ImageZoom } from '../../src'; 4 | import Slider from 'rc-slider'; 5 | import 'rc-slider/assets/index.css'; 6 | 7 | const catImg = 'http://www.catster.com/wp-content/uploads/2017/08/Pixiebob-cat.jpg'; 8 | const largeCatImg = 'https://www.nationalgeographic.com/content/dam/animals/thumbs/rights-exempt/mammals/d/domestic-cat_thumb.ngsversion.1472140774957.adapt.1900.1.jpg'; 9 | 10 | interface AppState { 11 | zoomWidth: number; 12 | zoomScale: number; 13 | isResponsive: boolean; 14 | } 15 | 16 | class App extends React.Component<{}, AppState> { 17 | constructor(props: any) { 18 | super(props); 19 | this.state = { zoomWidth: 540, zoomScale: 100, isResponsive: true }; 20 | } 21 | 22 | onZoomWidthSliderChange(val: number) { 23 | this.setState({zoomWidth: val}); 24 | } 25 | 26 | onZoomScaleSliderChange(val: number) { 27 | this.setState({zoomScale: val}); 28 | } 29 | 30 | toggleResponsive() { 31 | this.setState({isResponsive: !this.state.isResponsive}) 32 | } 33 | 34 | render() { 35 | const minScale = 540/1900; 36 | let zoomMarks = { 37 | 50: '.5', 38 | 66: '.66', 39 | 75: '.75', 40 | 100: '1' 41 | } 42 | zoomMarks[minScale * 100] = minScale.toFixed(2); 43 | return ( 44 |
45 |
46 |

React simple image zoom

47 |

Click image to start

48 |
49 | Responsive 50 |
51 |
52 |
53 |

zoomWidth: {this.state.zoomWidth}

54 | this.onZoomWidthSliderChange(v)} /> 55 |
56 |
57 |

zoomScale: {(this.state.zoomScale / 100).toFixed(2)}

58 | this.onZoomScaleSliderChange(v)} /> 59 |
60 |
61 |
62 |
63 |
64 | 69 | Cat image 70 | 71 |
72 | 73 |
74 |
75 |
76 | ) 77 | } 78 | } 79 | 80 | ReactDOM.render(, document.getElementById('appContainer')); -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": false, 4 | "lib": ["es6", "dom"], 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "target": "es5", 9 | "jsx": "react" 10 | }, 11 | "exclude": [ 12 | "**/*.spec.ts", 13 | "node_modules", 14 | "assets" 15 | ], 16 | "compileOnSave": false 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-simple-image-zoom", 3 | "version": "0.1.4", 4 | "description": "Simple image zoom/magnification component for react, using react portals", 5 | "main": "dist/ReactSimpleImageZoom.js", 6 | "types": "dist/index.d.ts", 7 | "homepage": "https://github.com/aaronlifton2/react-simple-image-zoom", 8 | "readme": "README.md", 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "dev": "node ./node_modules/webpack-dev-server/bin/webpack-dev-server.js", 12 | "build": "webpack" 13 | }, 14 | "keywords": [ 15 | "react", 16 | "image", 17 | "zoom", 18 | "image", 19 | "magnify", 20 | "react-simple-image-zoom", 21 | "image", 22 | "zoomer", 23 | "react", 24 | "portal" 25 | ], 26 | "author": "Aaron Lifton", 27 | "license": "MIT", 28 | "dependencies": { 29 | "react": "^16.3.0", 30 | "react-dom": "^16.3.0" 31 | }, 32 | "devDependencies": { 33 | "@types/node": "^9.6.1", 34 | "@types/react": "^16.1.0", 35 | "@types/react-dom": "^16.0.4", 36 | "copy-webpack-plugin": "^4.5.1", 37 | "css-loader": "^0.28.11", 38 | "rc-slider": "^8.6.1", 39 | "style-loader": "^0.20.3", 40 | "ts-loader": "^4.1.0", 41 | "typescript": "^2.8.1", 42 | "webpack": "^4.4.1", 43 | "webpack-cli": "^2.0.13", 44 | "webpack-dev-server": "^3.1.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ImageZoom.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ReactDOM from 'react-dom'; 3 | 4 | import ZoomContainer from './ZoomContainer'; 5 | import MagnifyingGlass, { MagnifyingGlassProps } from './MagnifyingGlass'; 6 | 7 | export interface ImageZoomProps { 8 | children: any; 9 | portalId: string; 10 | largeImgSrc?: string; 11 | imageWidth: number; 12 | imageHeight?: number; 13 | zoomContainerWidth: number; 14 | zoomContainerHeight?: number; 15 | activeClass?: string; 16 | portalStyle?: React.CSSProperties; 17 | portalClassName?: string; 18 | zoomScale?: number; 19 | responsive?: boolean; 20 | } 21 | 22 | export interface ImageZoomState { 23 | zoomX: number; 24 | zoomY: number; 25 | portalEl?: HTMLElement; 26 | isActive: boolean; 27 | glassX: number; 28 | glassY: number; 29 | glassWidth: number; 30 | glassHeight: number; 31 | zoomImageWidth?: number; 32 | zoomImageHeight?: number; 33 | scaleX: number; 34 | scaleY: number; 35 | offset?: any; 36 | offsetX: number; 37 | offsetY: number; 38 | lastScrollXPos?: number; 39 | lastScrollYPos?: number; 40 | zoomScale: number; 41 | imageWidth: number; 42 | imageHeight?: number; 43 | zoomContainerWidth: number; 44 | } 45 | 46 | export default class ImageZoom extends React.Component { 47 | zoomContainer: HTMLElement; 48 | image: HTMLElement; 49 | zoomImage: HTMLImageElement; 50 | imgSrc: string; 51 | portalStyle: React.CSSProperties; 52 | 53 | toggle: () => void; 54 | deactivate: () => void; 55 | onResize: () => void; 56 | 57 | public static defaultPortalStyle: React.CSSProperties = { 58 | position: 'absolute', 59 | width: '540px', 60 | zIndex: 1, 61 | }; 62 | 63 | constructor(props) { 64 | super(props); 65 | this.state = { 66 | isActive: false, 67 | portalEl: null, 68 | zoomX: 0, 69 | zoomY: 0, 70 | glassX: 0, 71 | glassY: 0, 72 | glassWidth: 40, // TODO: make 0 73 | glassHeight: 40, // TODO: make 0 74 | scaleX: 1, 75 | scaleY: 1, 76 | offsetX: 0, 77 | offsetY: 0, 78 | zoomScale: this.props.zoomScale || 1, 79 | imageWidth: this.props.imageWidth, 80 | imageHeight: this.props.imageHeight, 81 | zoomContainerWidth: this.props.zoomContainerWidth, 82 | }; 83 | 84 | this.portalStyle = this.props.portalStyle ? this.props.portalStyle : ImageZoom.defaultPortalStyle; 85 | 86 | this.onResize = this.onWindowResize.bind(this); 87 | this.toggle = this.toggleActive.bind(this); 88 | this.deactivate = this.setInactive.bind(this); 89 | this.zoom = this.zoom.bind(this); 90 | } 91 | 92 | getOffset(el: HTMLElement) { 93 | if (el) { 94 | const elRect = el.getBoundingClientRect(); 95 | return {left: elRect.left, top: elRect.top}; 96 | } 97 | return {left: 0, top: 0}; 98 | } 99 | 100 | calcScaleX(width: number, zoomScale = this.state.zoomScale) { 101 | return (this.zoomImage.naturalWidth * zoomScale) / width; 102 | } 103 | 104 | calcScaleY(height: number, zoomScale = this.state.zoomScale) { 105 | return (this.zoomImage.naturalHeight * zoomScale) / (height || this.zoomImage.height); 106 | } 107 | 108 | calcZoomImageWidth(zoomScale = this.state.zoomScale) { 109 | return this.zoomImage.naturalWidth * zoomScale; 110 | } 111 | 112 | calcZoomImageHeight(zoomScale = this.state.zoomScale) { 113 | return this.zoomImage.naturalHeight * zoomScale; 114 | } 115 | 116 | getPortalStyle() { 117 | if (this.props.responsive) { 118 | return Object.assign({...this.portalStyle}, {width: `${this.state.zoomContainerWidth}px`}); 119 | } else { 120 | return this.portalStyle; 121 | } 122 | } 123 | 124 | onWindowResize() { 125 | const newImageWidth = this.image.offsetWidth; 126 | const newImageHeight = this.image.offsetHeight; 127 | const scaleX = this.calcScaleX(newImageWidth); 128 | const scaleY = this.calcScaleY(newImageHeight); 129 | const offset = this.getOffset(this.image); 130 | this.setState({ 131 | offset, scaleX, scaleY, 132 | zoomContainerWidth: newImageWidth, 133 | glassWidth: newImageWidth / scaleX, 134 | glassHeight: newImageHeight / scaleY, 135 | imageWidth: newImageWidth, 136 | imageHeight: newImageHeight, 137 | }); 138 | } 139 | 140 | componentWillUnmount() { 141 | if (this.props.responsive) 142 | window.removeEventListener('resize', this.onResize); 143 | } 144 | 145 | componentDidMount() { 146 | let newImageHeight; 147 | if (this.props.responsive) 148 | window.addEventListener('resize', this.onResize); 149 | 150 | const image = new Image(); 151 | image.src = this.props.children.props.src; 152 | image.onload = () => { 153 | this.zoomImage = image; 154 | 155 | let newImageWidth, newImageHeight; 156 | if (this.props.responsive) 157 | newImageWidth = this.image.offsetWidth; 158 | newImageHeight = this.image.offsetHeight 159 | 160 | const scaleX = this.calcScaleX(newImageWidth || this.state.imageWidth); 161 | const scaleY = this.calcScaleY(newImageHeight || this.state.imageHeight); 162 | const offset = this.getOffset(this.image); 163 | const newState: Partial = { 164 | offset, scaleX, scaleY, 165 | zoomImageWidth: this.calcZoomImageWidth(), 166 | zoomImageHeight: this.calcZoomImageHeight(), 167 | glassWidth: this.props.zoomContainerWidth / scaleX, 168 | glassHeight: (newImageHeight || this.state.imageHeight) / scaleY, 169 | }; 170 | if (newImageWidth) newState.imageWidth = newImageWidth; 171 | if (newImageHeight) newState.imageHeight = newImageHeight; 172 | this.setState(newState as ImageZoomState); 173 | } 174 | 175 | if (this.props.largeImgSrc) { 176 | this.imgSrc = this.props.largeImgSrc; 177 | } else { 178 | this.imgSrc = this.props.children && this.props.children.props.src; 179 | } 180 | const portalEl = document.getElementById(this.props.portalId); 181 | this.setState({portalEl: portalEl}); 182 | } 183 | 184 | componentWillReceiveProps(newProps: ImageZoomProps) { 185 | if (!this.zoomImage) return null; 186 | 187 | const minScale = this.image.offsetWidth/this.zoomImage.naturalWidth; 188 | if (newProps.zoomScale < minScale) return null; 189 | 190 | const scaleX = this.calcScaleX(this.state.imageWidth, newProps.zoomScale); 191 | const scaleY = this.calcScaleY(this.state.imageHeight, newProps.zoomScale); 192 | 193 | let newGlassWidth, newZoomImageWidth; 194 | newZoomImageWidth = this.calcZoomImageWidth(newProps.zoomScale); 195 | newGlassWidth = newProps.zoomContainerWidth / scaleX; 196 | if (newGlassWidth > this.image.offsetWidth) { 197 | newGlassWidth = this.image.offsetWidth; 198 | }; 199 | if (newZoomImageWidth < newGlassWidth) { 200 | newZoomImageWidth = newGlassWidth; 201 | }; 202 | if (newProps.responsive && !this.props.responsive) { 203 | window.addEventListener('resize', this.onResize); 204 | }; 205 | if (newProps.responsive == false && this.props.responsive) { 206 | window.removeEventListener('resize', this.onResize); 207 | }; 208 | 209 | this.setState({ 210 | glassWidth: newGlassWidth, 211 | glassHeight: this.state.imageHeight / scaleY, 212 | scaleX, scaleY, 213 | zoomImageWidth: newZoomImageWidth, 214 | zoomContainerWidth: newProps.zoomContainerWidth, 215 | zoomImageHeight: this.calcZoomImageHeight(newProps.zoomScale), 216 | zoomScale: newProps.zoomScale 217 | }); 218 | } 219 | 220 | getGlassPos(evt: MouseEvent) { 221 | var a, x = 0, y = 0; 222 | const offset = this.getOffset(this.image); 223 | 224 | x = evt.pageX - offset.left; 225 | y = evt.pageY - offset.top; 226 | 227 | // check for page scroll 228 | x = x - window.pageXOffset; 229 | y = y - window.pageYOffset; 230 | 231 | let glassX, glassY; 232 | // calculate glass position 233 | glassX = x - (this.state.glassWidth / 2); 234 | glassY = y - (this.state.glassHeight / 2); 235 | 236 | 237 | // glass boundaries 238 | if (glassX > this.image.offsetWidth - this.state.glassWidth) {glassX = this.image.offsetWidth - this.state.glassWidth;} 239 | if (glassX < 0) glassX = 0; 240 | if (glassY > this.image.offsetHeight - this.state.glassHeight) {glassY = this.image.offsetHeight - this.state.glassHeight;} 241 | if (glassY < 0) glassY = 0; 242 | return {glassX, glassY}; 243 | } 244 | 245 | zoom(evt: React.SyntheticEvent): void { 246 | // Don't do anything if no image source, or is not currently active 247 | if (!this.imgSrc || !this.state.isActive) return null; 248 | 249 | const newState = this.getStateFromEvent(evt); 250 | this.setState(newState); 251 | } 252 | 253 | getPosition(v, min, max) { 254 | let val; 255 | if (v < min) val = min; 256 | if (v > max) val = max; 257 | if (!val) val = v; 258 | return val - min; 259 | } 260 | 261 | getZoomStateFromEvent(evt: MouseEvent): {x: number, y: number} { 262 | let x, y; 263 | let left = evt.clientX - this.state.offset.left; 264 | left = left + window.pageXOffset; // check for page scroll 265 | const leftMin = this.state.glassWidth / 2; 266 | const leftLimit = this.state.imageWidth - leftMin; 267 | const zoomLeft = this.getPosition(left, leftMin, leftLimit); 268 | 269 | let top = evt.clientY - this.state.offset.top; 270 | top = top + window.pageYOffset; // check for page scroll 271 | const topMin = this.state.glassHeight / 2; 272 | const topLimit = this.state.imageHeight - topMin; 273 | const zoomTop = this.getPosition(top, topMin, topLimit); 274 | 275 | x = zoomLeft * this.state.scaleX; 276 | y = zoomTop * this.state.scaleY; 277 | if (y < 0) y = 0; 278 | if (x < 0) x = 0; 279 | 280 | return {x, y}; 281 | } 282 | 283 | getStateFromEvent(synthEvt: React.SyntheticEvent) { 284 | const evt = synthEvt.nativeEvent as MouseEvent; 285 | 286 | const glassState = this.getGlassPos(evt); 287 | const zoomState = this.getZoomStateFromEvent(evt); 288 | 289 | return { 290 | zoomX: zoomState.x, 291 | zoomY: zoomState.y, 292 | ...glassState 293 | } 294 | } 295 | 296 | toggleActive(evt: React.SyntheticEvent) { 297 | if (this.state.isActive) { 298 | this.setState({isActive: false}); 299 | } else { 300 | let nativeEvent = evt.nativeEvent as MouseEvent; 301 | const newState = this.getStateFromEvent(evt); 302 | this.setState({ 303 | ...newState, 304 | isActive: true, 305 | }) 306 | } 307 | } 308 | 309 | setInactive() { 310 | this.setState({ isActive: false }) 311 | } 312 | 313 | render() { 314 | if (!this.state.portalEl) { 315 | console.log('no portalEl'); 316 | return null; 317 | } 318 | 319 | const imageContainerClass = 'image-zoom-container' + (this.state.isActive 320 | ? ' ' + (this.props.activeClass || "active") 321 | : '' 322 | ); 323 | 324 | return ( 325 | 326 | {this.state.isActive && this.state.zoomImageWidth && 327 | ReactDOM.createPortal( 328 |
this.zoomContainer = el} 329 | style={this.portalStyle} 330 | className={this.props.portalClassName} 331 | > 332 | 342 |
, 343 | this.state.portalEl, 344 | ) 345 | } 346 |
this.image = el} 348 | onClick={this.toggle} 349 | onMouseLeave={this.deactivate} 350 | onMouseMove={this.zoom} 351 | className={imageContainerClass} 352 | > 353 | {this.state.isActive && this.state.offset && 354 | 360 | } 361 | {this.props.children} 362 |
363 |
364 | ) 365 | } 366 | } -------------------------------------------------------------------------------- /src/MagnifyingGlass.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export interface MagnifyingGlassProps { 4 | x: number; 5 | y: number; 6 | width: number; 7 | height: number; 8 | } 9 | 10 | const MagnifyingGlass = (props: MagnifyingGlassProps) => { 11 | return
23 | }; 24 | 25 | export default MagnifyingGlass; -------------------------------------------------------------------------------- /src/ZoomContainer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ImageZoomState } from './ImageZoom'; 3 | 4 | const ZoomContainer = (props: Partial & {zoomContainerHeight: number, imgSrc: string}) => { 5 | return ( 6 |
19 | ); 20 | } 21 | 22 | export default ZoomContainer; -------------------------------------------------------------------------------- /src/experimental/scrollZoom.ts: -------------------------------------------------------------------------------- 1 | class ScrollZoom { 2 | state: any; 3 | setState: (any) => void; 4 | image: HTMLImageElement; 5 | 6 | attachPageScrollListener() { 7 | window.addEventListener('scroll', (e: Event) => { 8 | const pageXOffset = window.pageXOffset; 9 | const pageYOffset = window.pageYOffset; 10 | const newZoomState = this.getZoomStateFromCurrentState(pageXOffset, pageYOffset) 11 | const newGlassState = this.getGlassStateFromCurrentState(pageXOffset, pageYOffset) 12 | this.setState({ 13 | lastScrollXPos: pageXOffset, 14 | lastScrollYPos: pageYOffset, 15 | ...newZoomState, 16 | ...newGlassState 17 | }); 18 | }); 19 | } 20 | 21 | getGlassStateFromCurrentState(pageXOffset: number, pageYOffset: number) { 22 | let x, y; 23 | if (this.state.lastScrollXPos > pageXOffset) { 24 | x = this.state.glassX - pageXOffset; 25 | } else { 26 | x = this.state.glassX + pageXOffset; 27 | }; 28 | y = this.state.glassY + pageYOffset; 29 | if (x > this.image.offsetWidth - this.state.glassWidth) {x = this.image.offsetWidth - this.state.glassWidth;} 30 | if (x < 0) x = 0; 31 | if (y > this.image.offsetHeight - this.state.glassHeight) {y = this.image.offsetHeight - this.state.glassHeight;} 32 | if (y < 0) y = 0; 33 | return {glassX: x, glassY: y}; 34 | } 35 | 36 | getZoomStateFromCurrentState(pageXOffset: number, pageYOffset: number) { 37 | let x, y; 38 | if (this.state.lastScrollXPos > pageXOffset) { 39 | x = this.state.zoomX - (pageXOffset / this.state.scaleX); 40 | } else { 41 | x = this.state.zoomX + (pageXOffset / this.state.scaleX); 42 | }; 43 | if (this.state.lastScrollYPos > pageYOffset) { 44 | y = this.state.zoomY + (pageYOffset / this.state.scaleY); 45 | } else { 46 | y = this.state.zoomY - (pageYOffset / this.state.scaleY); 47 | }; 48 | return {zoomX: x, zoomY: y}; 49 | } 50 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {default as ImageZoom} from './ImageZoom'; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "lib": ["es6", "dom"], 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "rootDir": "./src", 8 | "outDir": "./", 9 | "sourceMap": true, 10 | "target": "es6", 11 | "jsx": "react" 12 | }, 13 | "files": [ 14 | "./src/index.ts" 15 | ], 16 | "types": [ 17 | "node" 18 | ], 19 | "exclude": [ 20 | "node_modules", 21 | "**/*.spec.ts", 22 | "**/*.d.ts", 23 | "dist" 24 | ], 25 | "compileOnSave": false 26 | } 27 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 3 | 4 | module.exports = [{ 5 | entry: './src/index.ts', 6 | mode: 'production', 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.tsx?$/, 11 | use: 'ts-loader', 12 | exclude: [ 13 | /node_modules/, 14 | "./src/experimental" 15 | ] 16 | } 17 | ] 18 | }, 19 | resolve: { 20 | extensions: [ '.tsx', '.ts', '.js' ] 21 | }, 22 | output: { 23 | filename: 'ReactSimpleImageZoom.js', 24 | path: path.resolve(__dirname, 'dist'), 25 | libraryTarget: 'umd' 26 | }, 27 | externals: { 28 | 'react': { 29 | umd: 'react', 30 | commonjs: 'react', 31 | commonjs2: 'react', 32 | amd: 'react', 33 | root: 'React' 34 | }, 35 | 'react-dom': { 36 | umd: 'react-dom', 37 | commonjs: 'react-dom', 38 | commonjs2: 'react-dom', 39 | amd: 'react-dom', 40 | root: 'ReactDOM' 41 | } 42 | }, 43 | devServer: { 44 | open: true, 45 | openPage: 'docs', 46 | publicPath: '/docs/', 47 | } 48 | }, { 49 | devtool: 'source-map', 50 | entry: './example/src/App.tsx', 51 | mode: 'development', 52 | module: { 53 | rules: [ 54 | { 55 | test: /\.tsx?$/, 56 | use: { 57 | loader: 'ts-loader', 58 | options: { 59 | configFile: "example/tsconfig.json" 60 | } 61 | }, 62 | exclude: /node_modules/ 63 | }, 64 | { 65 | test: /\.css$/, 66 | use: ['style-loader', 'css-loader'] 67 | } 68 | ] 69 | }, 70 | plugins: [ 71 | new CopyWebpackPlugin([ 72 | { 73 | from: path.resolve(__dirname, 'example/index.html'), 74 | to: path.resolve(__dirname, 'docs') 75 | } 76 | ]) 77 | ], 78 | resolve: { 79 | extensions: [ '.tsx', '.ts', '.js' ] 80 | }, 81 | output: { 82 | filename: 'bundle.js', 83 | path: path.resolve(__dirname, 'docs/dist') 84 | } 85 | }]; --------------------------------------------------------------------------------