├── .gitignore ├── .npmignore ├── .storybook ├── addons.js ├── config.js └── webpack.config.js ├── Readme.md ├── babel.config.js ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── index.test.ts ├── index.tsx └── use-previous.ts ├── stories └── intro.stories.tsx ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | node_modules 3 | build 4 | dist 5 | .rpt2_cache 6 | .DS_Store 7 | .env 8 | .env.local 9 | .env.development.local 10 | .env.test.local 11 | .env.production.local 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | esm 16 | cjs -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmcmahen/react-image-enlarger/7f06450ca3f2b3c36bb6216470ceff3ee999f407/.npmignore -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmcmahen/react-image-enlarger/7f06450ca3f2b3c36bb6216470ceff3ee999f407/.storybook/addons.js -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from "@storybook/react"; 2 | 3 | const req = require.context("../stories", true, /.stories.tsx$/); 4 | 5 | function loadStories() { 6 | req.keys().forEach(filename => req(filename)); 7 | } 8 | 9 | configure(loadStories, module); 10 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = async ({ config, mode }) => { 2 | config.module.rules.push({ 3 | test: /\.(ts|tsx)$/, 4 | use: [ 5 | { 6 | loader: require.resolve("babel-loader") 7 | }, 8 | { 9 | loader: require.resolve("awesome-typescript-loader") 10 | } 11 | ] 12 | }); 13 | 14 | config.resolve.extensions.push(".ts", ".tsx", ".json"); 15 | return config; 16 | }; 17 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # react-image-enlarger 2 | 3 | [![npm package](https://img.shields.io/npm/v/react-image-enlarger/latest.svg)](https://www.npmjs.com/package/react-image-enlarger) 4 | [![Follow on Twitter](https://img.shields.io/twitter/follow/benmcmahen.svg?style=social&logo=twitter)](https://twitter.com/intent/follow?screen_name=benmcmahen) 5 | 6 | A medium.com style image zoom component with gesture dismissal similar to that found in the iOS Photos app. Originally built for use in [Sancho-UI](https://github.com/bmcmahen/sancho). Try the [demo here](https://codesandbox.io/embed/adoring-sun-dz5yj). 7 | 8 | ## Features 9 | 10 | - Drag to dismiss 11 | - Optionally use a differernt enlarged image source 12 | - Optional loading indicator when loading the enlarged image 13 | - Spring based animations 14 | 15 | ## Install 16 | 17 | Install `react-image-enlarger` and `react-gesture-responder` using yarn or npm. 18 | 19 | ``` 20 | yarn add react-image-enlarger react-gesture-responder 21 | ``` 22 | 23 | ## Usage 24 | 25 | ```jsx 26 | import Image from "react-image-enlarger"; 27 | 28 | function SingleSource() { 29 | const [zoomed, setZoomed] = React.useState(false); 30 | 31 | return ( 32 | The best dog ever setZoomed(true)} 38 | onRequestClose={() => setZoomed(false)} 39 | /> 40 | ); 41 | } 42 | ``` 43 | 44 | ## API 45 | 46 | Any additional props beyond the ones listed below are passed to the thumbnail image. 47 | 48 | | Name | Type | Default Value | Description | 49 | | ---------------- | --------------- | ------------- | ---------------------------------------------------------- | 50 | | zoomed\* | boolean | | Whether the enlarged image is shown | 51 | | onRequestClose\* | () => void; | | A callback for closing the zoomed image | 52 | | renderLoading | React.ReactNode | | Render a loading indicator | 53 | | src\* | String | | The thumbnail image source (and enlarged, if not provided) | 54 | | enlargedSrc | String | | An optional larger image source | 55 | | overlayColor | String | | Customize the background color of the overlay | 56 | 57 | ## License 58 | 59 | MIT 60 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | useBuiltIns: "usage", 7 | corejs: 2, 8 | targets: { node: "6" } 9 | } 10 | ], 11 | "@babel/preset-react", 12 | "@babel/preset-typescript" 13 | ], 14 | env: { 15 | test: { 16 | plugins: ["require-context-hook"] 17 | } 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src"], 3 | transform: { 4 | "^.+\\.tsx?$": "ts-jest" 5 | }, 6 | testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 7 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"] 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-image-enlarger", 3 | "version": "1.0.2", 4 | "description": "A medium.com style image zoom component with gesture dismissal", 5 | "main": "cjs/index.js", 6 | "module": "esm/index.js", 7 | "typings": "esm/index.d.ts", 8 | "author": "Ben McMahen", 9 | "license": "MIT", 10 | "private": false, 11 | "scripts": { 12 | "test": "jest", 13 | "test-watch": "jest -w", 14 | "storybook": "start-storybook -p 6006", 15 | "build-esm": "rimraf esm && tsc", 16 | "build-other": "rimraf umd && rimraf cjs && rollup -c", 17 | "build": "yarn run build-esm && yarn run build-other", 18 | "prepublishOnly": "yarn run build" 19 | }, 20 | "peerDependencies": { 21 | "react": "^16.8.6", 22 | "react-dom": "^16.8.6" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.4.0", 26 | "@babel/preset-env": "^7.4.2", 27 | "@babel/preset-react": "^7.0.0", 28 | "@babel/preset-typescript": "^7.3.3", 29 | "@storybook/react": "^5.0.5", 30 | "@types/jest": "^24.0.11", 31 | "@types/storybook__react": "^4.0.1", 32 | "awesome-typescript-loader": "^5.2.1", 33 | "babel-core": "^6.26.3", 34 | "babel-jest": "^24.5.0", 35 | "babel-loader": "^8.0.5", 36 | "babel-plugin-require-context-hook": "^1.0.0", 37 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 38 | "jest": "^24.5.0", 39 | "react": "^16.8.6", 40 | "react-dom": "^16.8.6", 41 | "react-gesture-responder": "^2.1.0", 42 | "rimraf": "^2.6.3", 43 | "rollup": "^1.7.4", 44 | "rollup-plugin-babel": "^4.3.2", 45 | "rollup-plugin-cleanup": "^3.1.1", 46 | "rollup-plugin-commonjs": "^9.2.2", 47 | "rollup-plugin-filesize": "^6.0.1", 48 | "rollup-plugin-json": "^4.0.0", 49 | "rollup-plugin-node-resolve": "^4.0.1", 50 | "rollup-plugin-sourcemaps": "^0.4.2", 51 | "rollup-plugin-typescript2": "^0.20.1", 52 | "rollup-plugin-uglify": "^6.0.2", 53 | "ts-jest": "^24.0.1", 54 | "typescript": "^3.5.3", 55 | "webpack": "^4.29.6" 56 | }, 57 | "dependencies": { 58 | "@types/react": "^16.8.10", 59 | "@types/react-dom": "^16.8.3", 60 | "react-spring": "^9.0.0-beta.31", 61 | "tslib": "^1.9.3", 62 | "use-scroll-lock": "^1.0.0" 63 | }, 64 | "sideEffects": false 65 | } 66 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "rollup-plugin-node-resolve"; 2 | import filesize from "rollup-plugin-filesize"; 3 | import pkg from "./package.json"; 4 | import commonjs from "rollup-plugin-commonjs"; 5 | import cleanup from "rollup-plugin-cleanup"; 6 | import json from "rollup-plugin-json"; 7 | import typescript from "rollup-plugin-typescript2"; 8 | 9 | const input = "src/index.tsx"; 10 | 11 | const plugins = [ 12 | resolve(), 13 | typescript({ 14 | typescript: require("typescript") 15 | }), 16 | commonjs(), 17 | json(), 18 | cleanup(), 19 | filesize() 20 | ]; 21 | 22 | const externals = [ 23 | ...Object.keys(pkg.dependencies || {}), 24 | ...Object.keys(pkg.peerDependencies || {}) 25 | ]; 26 | 27 | export default [ 28 | { 29 | input, 30 | output: [ 31 | { 32 | file: pkg.main, 33 | format: "cjs", 34 | sourcemap: true 35 | } 36 | ], 37 | external: externals, 38 | plugins 39 | } 40 | ]; 41 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | test("hello", () => { 2 | expect("hello").toBeTruthy(); 3 | }); 4 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useSpring, animated, config } from "react-spring"; 3 | import { usePrevious } from "./use-previous"; 4 | import { useGestureResponder, StateType } from "react-gesture-responder"; 5 | import useScrollLock from "use-scroll-lock"; 6 | 7 | interface PositionType { 8 | x: number; 9 | y: number; 10 | w: number; 11 | h: number; 12 | } 13 | 14 | export interface ImageEnlargerProps extends React.HTMLAttributes { 15 | zoomed: boolean; 16 | onClick: () => void; 17 | enlargedSrc?: string; 18 | overlayColor?: string; 19 | renderLoading?: React.ReactNode; 20 | onRequestClose: () => void; 21 | src: string; 22 | } 23 | 24 | const initialTransform = "translateX(0px) translateY(0px) scale(1)"; 25 | const getScale = linearConversion([0, 400], [1, 0.4]); 26 | const scaleClamp = clamp(0.4, 1); 27 | 28 | /** 29 | * Image component 30 | * @param param0 31 | */ 32 | 33 | const ImageEnlarger: React.FunctionComponent = ({ 34 | zoomed = false, 35 | renderLoading, 36 | overlayColor = "rgba(255,255,255,0.8)", 37 | enlargedSrc, 38 | onRequestClose, 39 | style = {}, 40 | src, 41 | ...other 42 | }) => { 43 | const ref = React.useRef(null); 44 | const prevZoom = usePrevious(zoomed); 45 | const [animating, setAnimating] = React.useState(false); 46 | const cloneRef = React.useRef(null); 47 | const [cloneLoaded, setCloneLoaded] = React.useState(false); 48 | const prevCloneLoaded = usePrevious(cloneLoaded); 49 | const [hasRequestedZoom, setHasRequestedZoom] = React.useState(zoomed); 50 | 51 | // this allows us to lazily load our cloned image 52 | React.useEffect(() => { 53 | if (!hasRequestedZoom && zoomed) { 54 | setHasRequestedZoom(true); 55 | } 56 | }, [hasRequestedZoom, zoomed]); 57 | 58 | // disable scrolling while zooming is taking place 59 | useScrollLock(zoomed || animating); 60 | 61 | /** 62 | * We basically only use this to imperatively set the 63 | * visibility of the thumbnail 64 | */ 65 | 66 | const [thumbProps, setThumbProps] = useSpring(() => ({ 67 | opacity: 1, 68 | immediate: true 69 | })); 70 | 71 | // set overlay opacity 72 | const [overlay, setOverlay] = useSpring(() => ({ 73 | opacity: zoomed ? 1 : 0 74 | })); 75 | 76 | // our cloned image spring 77 | const [props, set] = useSpring(() => ({ 78 | opacity: 0, 79 | transform: initialTransform, 80 | left: 0, 81 | top: 0, 82 | width: 0, 83 | height: 0, 84 | immediate: true, 85 | config: config.stiff 86 | })); 87 | 88 | /** 89 | * Handle drag movement 90 | */ 91 | 92 | function onMove({ delta }: StateType) { 93 | const scale = scaleClamp(getScale(Math.abs(delta[1]))); 94 | 95 | // we use this to alter the y-position to ensure the image 96 | // scales under our cursor / pointer 97 | const diffHeight = ((1 - scale) * cloneRef.current!.height) / 2; 98 | const ty = delta[1] - diffHeight * (delta[1] > 0 ? 1 : -1); 99 | 100 | set({ 101 | transform: `translateX(${delta[0] * 102 | 0.8}px) translateY(${ty}px) scale(${scale})`, 103 | immediate: true 104 | }); 105 | 106 | setOverlay({ opacity: scale, immediate: true }); 107 | } 108 | 109 | /** 110 | * Handle release - we almost always close 111 | * @param param0 112 | */ 113 | 114 | function onEnd({ delta }: StateType) { 115 | if (Math.abs(delta[1]) > 20 && onRequestClose) { 116 | onRequestClose(); 117 | } else { 118 | // reset 119 | set({ transform: initialTransform, immediate: false }); 120 | setOverlay({ opacity: 1, immediate: false }); 121 | } 122 | } 123 | 124 | // our gesture binding helper 125 | const { bind } = useGestureResponder({ 126 | onStartShouldSet: () => false, 127 | onMoveShouldSet: () => { 128 | if (!zoomed) { 129 | return false; 130 | } 131 | 132 | return true; 133 | }, 134 | onMove: onMove, 135 | onRelease: onEnd, 136 | onTerminate: onEnd 137 | }); 138 | 139 | /** 140 | * Basic logic for determining positions. A bit of a mess tbh, 141 | * but that often comes when mixing imperative logic w/ 142 | * react's declarative nature. 143 | */ 144 | 145 | const generatePositions = React.useCallback( 146 | (immediate: boolean = false) => { 147 | // any time this prop changes, we update our position 148 | if (ref.current && cloneLoaded) { 149 | const rect = ref.current.getBoundingClientRect(); 150 | 151 | const cloneSize = { 152 | width: cloneRef.current!.naturalWidth, 153 | height: cloneRef.current!.naturalHeight 154 | }; 155 | 156 | const thumbDimensions = { 157 | x: rect.left, 158 | y: rect.top, 159 | w: rect.width, 160 | h: rect.height 161 | }; 162 | 163 | const clonedDimensions = getTargetDimensions( 164 | cloneSize.width, 165 | cloneSize.height 166 | ); 167 | 168 | const initialSize = getInitialClonedDimensions( 169 | thumbDimensions, 170 | clonedDimensions 171 | ); 172 | 173 | const zoomingIn = 174 | (!prevZoom && zoomed) || (!prevCloneLoaded && cloneLoaded); 175 | const zoomingOut = prevZoom && !zoomed; 176 | 177 | // handle zooming in 178 | if (zoomingIn && !immediate) { 179 | setThumbProps({ opacity: 0, immediate: true }); 180 | set({ 181 | opacity: 1, 182 | immediate: true, 183 | transform: `translateX(${initialSize.translateX}px) translateY(${ 184 | initialSize.translateY 185 | }px) scale(${initialSize.scale})`, 186 | left: clonedDimensions.x, 187 | top: clonedDimensions.y, 188 | width: clonedDimensions.w, 189 | height: clonedDimensions.h, 190 | onRest: () => {} 191 | }); 192 | 193 | set({ 194 | transform: initialTransform, 195 | immediate: false 196 | }); 197 | 198 | // handle zooming out 199 | } else if (zoomingOut) { 200 | setAnimating(true); 201 | 202 | set({ 203 | transform: `translateX(${initialSize.translateX}px) translateY(${ 204 | initialSize.translateY 205 | }px) scale(${initialSize.scale})`, 206 | immediate: false, 207 | onRest: () => { 208 | setThumbProps({ opacity: 1, immediate: true }); 209 | set({ opacity: 0, immediate: true }); 210 | setAnimating(false); 211 | } 212 | }); 213 | 214 | // handle resizing 215 | } else if (immediate) { 216 | set({ 217 | immediate: true, 218 | transform: initialTransform, 219 | left: clonedDimensions.x, 220 | top: clonedDimensions.y, 221 | width: clonedDimensions.w, 222 | height: clonedDimensions.h, 223 | onRest: () => {} 224 | }); 225 | } 226 | 227 | setOverlay({ opacity: zoomed ? 1 : 0 }); 228 | } 229 | }, 230 | [ 231 | zoomed, 232 | cloneLoaded, 233 | ref, 234 | cloneRef, 235 | prevCloneLoaded, 236 | hasRequestedZoom, 237 | prevZoom 238 | ] 239 | ); 240 | 241 | // we need to update our fixed positioning when resizing 242 | // this should probably be debounced 243 | const onResize = React.useCallback(() => { 244 | generatePositions(true); 245 | }, [zoomed, cloneLoaded, ref, prevCloneLoaded, prevZoom]); 246 | 247 | // update our various positions 248 | React.useEffect(() => { 249 | generatePositions(); 250 | if (zoomed) window.addEventListener("resize", onResize); 251 | return () => { 252 | window.removeEventListener("resize", onResize); 253 | }; 254 | }, [zoomed, cloneLoaded, ref, prevCloneLoaded, prevZoom]); 255 | 256 | return ( 257 | 258 |
259 |
263 | 276 | {!cloneLoaded && zoomed && renderLoading} 277 |
278 |
279 | {hasRequestedZoom && ( 280 |
296 | 308 | 309 | { 312 | setCloneLoaded(true); 313 | }} 314 | style={{ 315 | pointerEvents: "none", 316 | zIndex: 100, 317 | position: "absolute", 318 | opacity: props.opacity, 319 | transform: props.transform, 320 | left: props.left, 321 | top: props.top, 322 | width: props.width, 323 | height: props.height 324 | }} 325 | ref={cloneRef} 326 | src={enlargedSrc || src} 327 | /> 328 |
329 | )} 330 |
331 | ); 332 | }; 333 | 334 | /** 335 | * Get the original dimensions of the clone so that it appears 336 | * in the same place as the original image 337 | * @param o origin 338 | * @param t target 339 | */ 340 | 341 | function getInitialClonedDimensions(o: PositionType, t: PositionType) { 342 | const scale = o.w / t.w; 343 | const translateX = o.x + o.w / 2 - (t.x + t.w / 2); 344 | const translateY = o.y + o.h / 2 - (t.y + t.h / 2); 345 | return { 346 | scale, 347 | translateX, 348 | translateY 349 | }; 350 | } 351 | 352 | /** 353 | * Get the target dimensions / position of the image when 354 | * it's zoomed in 355 | * 356 | * @param iw (image width) 357 | * @param ih (image height) 358 | * @param padding 359 | */ 360 | 361 | function getTargetDimensions(iw: number, ih: number, padding = 0) { 362 | const vp = getViewport(); 363 | const target = scaleToBounds(iw, ih, vp.width - padding, vp.height - padding); 364 | const left = vp.width / 2 - target.width / 2; 365 | const top = vp.height / 2 - target.height / 2; 366 | return { 367 | x: left, 368 | y: top, 369 | w: target.width, 370 | h: target.height 371 | }; 372 | } 373 | 374 | /** 375 | * Scale numbers to bounds given max dimensions while 376 | * maintaining the original aspect ratio 377 | * 378 | * @param ow 379 | * @param oh 380 | * @param mw 381 | * @param mh 382 | */ 383 | 384 | function scaleToBounds(ow: number, oh: number, mw: number, mh: number) { 385 | let scale = Math.min(mw / ow, mh / oh); 386 | if (scale > 1) scale = 1; 387 | return { 388 | width: ow * scale, 389 | height: oh * scale 390 | }; 391 | } 392 | 393 | /** 394 | * Server-safe measurement of the viewport size 395 | */ 396 | 397 | function getViewport() { 398 | if (typeof window !== "undefined") { 399 | return { width: window.innerWidth, height: window.innerHeight }; 400 | } 401 | 402 | return { width: 0, height: 0 }; 403 | } 404 | 405 | /** 406 | * Create a basic linear conversion fn 407 | */ 408 | 409 | function linearConversion(a: [number, number], b: [number, number]) { 410 | const o = a[1] - a[0]; 411 | const n = b[1] - b[0]; 412 | 413 | return function(x: number) { 414 | return ((x - a[0]) * n) / o + b[0]; 415 | }; 416 | } 417 | 418 | /** 419 | * Create a clamp 420 | * @param max 421 | * @param min 422 | */ 423 | 424 | function clamp(min: number, max: number) { 425 | return function(x: number) { 426 | if (x > max) return max; 427 | if (x < min) return min; 428 | return x; 429 | }; 430 | } 431 | 432 | export default ImageEnlarger; 433 | -------------------------------------------------------------------------------- /src/use-previous.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | export function usePrevious(value: T) { 4 | const ref = React.useRef(null); 5 | React.useEffect(() => { 6 | ref.current = value; 7 | }, [value]); 8 | return ref.current; 9 | } 10 | -------------------------------------------------------------------------------- /stories/intro.stories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import Image from "../src"; 4 | 5 | function SingleSource({ ...other }) { 6 | const [zoomed, setZoomed] = React.useState(false); 7 | 8 | return ( 9 | setZoomed(true)} 14 | onRequestClose={() => setZoomed(false)} 15 | {...other} 16 | /> 17 | ); 18 | } 19 | 20 | function DoubleSource() { 21 | const [zoomed, setZoomed] = React.useState(false); 22 | 23 | return ( 24 | setZoomed(true)} 30 | onRequestClose={() => setZoomed(false)} 31 | /> 32 | ); 33 | } 34 | 35 | function LoadingIndicator() { 36 | const [zoomed, setZoomed] = React.useState(false); 37 | 38 | return ( 39 | 52 | Loading! 53 | 54 | } 55 | src="https://images.unsplash.com/photo-1542049943447-072b93eb20af?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=275&q=80" 56 | enlargedSrc="https://images.unsplash.com/photo-1542049943447-072b93eb20af?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=875&q=80" 57 | onClick={() => setZoomed(true)} 58 | onRequestClose={() => setZoomed(false)} 59 | /> 60 | ); 61 | } 62 | 63 | storiesOf("Hello", module) 64 | .add("Single src", () => ( 65 | <> 66 | 67 | 68 | 69 | )) 70 | .add("Double source", () => ) 71 | .add("Loading indicator", () => ) 72 | .add("Custom background", () => ); 73 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": true, 6 | "declarationDir": "esm", 7 | "esModuleInterop": true, 8 | "importHelpers": true, 9 | "jsx": "react", 10 | "lib": ["es2015", "esnext.asynciterable", "dom"], 11 | "module": "esnext", 12 | "target": "es5", 13 | "outDir": "esm", 14 | "skipLibCheck": true, 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "isolatedModules": false, 18 | "strict": true, 19 | "forceConsistentCasingInFileNames": true, 20 | "allowJs": false 21 | }, 22 | "includes": ["src"], 23 | "exclude": [ 24 | "node_modules", 25 | "dist", 26 | "stories", 27 | "tests", 28 | "esm", 29 | "src/**/__tests__" 30 | ] 31 | } 32 | --------------------------------------------------------------------------------