├── .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 | 
9 |
10 | [](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 |
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 |
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 | }];
--------------------------------------------------------------------------------