├── .github └── workflows │ ├── publish-to-npm.yml │ ├── test-with-cypress.yml │ └── update-gh-pages.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel-build-config.js ├── cypress.config.js ├── cypress ├── e2e │ └── react-modal-image.cy.js └── fixtures │ └── .gitkeep ├── demo ├── public │ ├── example_img_large.jpg │ ├── example_img_medium.jpg │ ├── example_img_small.jpg │ └── example_transparent_heart.png ├── src │ ├── index.html │ └── index.js └── webpack.config.js ├── package-lock.json ├── package.json └── src ├── Header.js ├── Image.js ├── Lightbox.js ├── icons.js ├── index.js └── styles.js /.github/workflows/publish-to-npm.yml: -------------------------------------------------------------------------------- 1 | name: publish-to-npm 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | publish-to-npm: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: 18 15 | registry-url: https://registry.npmjs.org 16 | - run: npm ci && npm run build 17 | - run: npm publish 18 | env: 19 | NODE_AUTH_TOKEN: ${{secrets.NPM_AUTH_TOKEN}} -------------------------------------------------------------------------------- /.github/workflows/test-with-cypress.yml: -------------------------------------------------------------------------------- 1 | name: Cypress Tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | cypress-run: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - uses: actions/setup-node@v3 11 | with: 12 | node-version: 18 13 | - uses: cypress-io/github-action@v4 14 | with: 15 | build: npm run build -------------------------------------------------------------------------------- /.github/workflows/update-gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Update gh pages from demo/dist/ 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 18 16 | - run: npm ci && npm run build 17 | - uses: actions/upload-pages-artifact@v1 18 | with: 19 | path: 'demo/dist/' 20 | deploy: 21 | needs: build 22 | 23 | permissions: 24 | pages: write # to deploy to Pages 25 | id-token: write # to verify the deployment originates from an appropriate source 26 | 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/deploy-pages@v1 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log* 8 | .vscode 9 | cypress/screenshots 10 | cypress/downloads -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Installation 2 | 3 | - Running `npm install` in the component's root directory will install everything you need for development. 4 | 5 | ## Demo Development Server 6 | 7 | - `npm run start-demo` will run a development server with the component's demo app with hot module reloading. 8 | 9 | ## Running Tests 10 | 11 | - `npm run build && npm run cypress:run` to run the tests once. 12 | - `npm run cypress:open` helps to debug any issues with Cypress. 13 | 14 | ## Building 15 | 16 | - `npm run build` will build the component for publishing to npmjs and also bundle the demo app. 17 | 18 | - `npm run clean` will delete built resources. 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Ari Autio 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-modal-image 2 | 3 | [![npm package][npm-badge]][npm] 4 | 5 | A _lightweight_ React component providing modal image Lightbox. 6 | 7 | [DEMO](https://aautio.github.io/react-modal-image/) 8 | 9 | ## Features 10 | 11 | - Only _3 kB_ when gzipped. 12 | - Zero dependencies. 13 | - Includes builds for CommonJS and ES modules. 14 | - For React 16.x, 17.x and 18.x. 15 | - Esc, Enter & click outside the image close the lightbox 16 | - User can zoom & move the image or download the highest quality one 17 | - Download and Zoom -buttons can be hidden. 18 | - Image alt shown as title of lightbox 19 | - Background color of transparent images can be overridden. 20 | 21 | You need to bring your own `Set` and `fetch` polyfills if you use old Internet Explorers. 22 | 23 | ## Simple API 24 | 25 | ```js 26 | import ModalImage from "react-modal-image"; 27 | 28 | ; 33 | ``` 34 | 35 | | Prop | Type | Description | 36 | | ---------------------- | --------- | ------------------------------------------------------------------------------------------------------------- | 37 | | `className` | `String` | Optional. `class` for the small preview image. | 38 | | `alt` | `String` | Optional. `alt` for the small image and the heading text in Lightbox. | 39 | | `small` | `URL` | `src` for the small preview image. | 40 | | `smallSrcSet` | `String` | Optional. `srcSet` for the small preview image. | 41 | | `medium` | `URL` | Optional if `large` is defined. Image shown when zoomed out in Lightbox. | 42 | | `large` | `URL` | Optional if `medium` is defined. Image shown when zoomed in Lightbox. Downloadable. | 43 | | `hideDownload` | `boolean` | Optional. Set to `true` to hide download-button from the Lightbox. | 44 | | `hideZoom` | `boolean` | Optional. Set to `true` to hide zoom-button from the Lightbox. | 45 | | `showRotate` | `boolean` | Optional. Set to `true` to show rotate-button within the Lightbox. | 46 | | `imageBackgroundColor` | `String` | Optional. Background color of the image shown in Lightbox. Defaults to black. Handy for transparent images.   | 47 | 48 | ## Lightbox-only API for advanced usage 49 | 50 | You can also choose to import only the Lightbox. 51 | 52 | To use the Lightbox only, you'll need to handle the open state by yourself: 53 | 54 | ```js 55 | import { Lightbox } from "react-modal-image"; 56 | 57 | // ... 58 | 59 | const closeLightbox = () => { 60 | this.state.open = true; 61 | }; 62 | 63 | // ... 64 | 65 | { 66 | this.state.open && ( 67 | 73 | ); 74 | } 75 | ``` 76 | 77 | | Prop | Type | Description | 78 | | --------- | ---------- | ------------------------------------------------------- | 79 | | `onClose` | `function` | Will be invoked when the Lightbox requests to be closed | 80 | 81 | [npm-badge]: https://img.shields.io/npm/v/react-modal-image.svg 82 | [npm]: https://www.npmjs.org/package/react-modal-image 83 | -------------------------------------------------------------------------------- /babel-build-config.js: -------------------------------------------------------------------------------- 1 | const moduleFormat = process.env.MODULES; 2 | 3 | const assert = require("assert"); 4 | assert( 5 | ["commonjs", "es6"].includes(moduleFormat), 6 | "Undefined module format! Choose either commonjs or es6" 7 | ); 8 | 9 | module.exports = function(api) { 10 | api.cache(true); 11 | 12 | const presets = [ 13 | [ 14 | "@babel/preset-env", 15 | { 16 | modules: moduleFormat === "es6" ? false : moduleFormat 17 | } 18 | ], 19 | "@babel/preset-react" 20 | ]; 21 | const plugins = ["@babel/plugin-proposal-class-properties"]; 22 | 23 | return { 24 | presets, 25 | plugins 26 | }; 27 | }; 28 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('cypress') 2 | 3 | module.exports = defineConfig({ 4 | video: false, 5 | e2e: { 6 | setupNodeEvents(on, config) {}, 7 | supportFile: false, 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /cypress/e2e/react-modal-image.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe("react-modal-image", function() { 4 | it("can find seven images in the demo", function() { 5 | cy.visit("demo/dist/index.html"); 6 | 7 | cy.contains("react-modal-image"); 8 | cy.contains("#1 with alt, small, medium and large props"); 9 | cy.contains("#2 with small and large props defined only"); 10 | cy.contains("#3 with small and medium props defined only"); 11 | cy.contains("#4 with download and zoom -buttons hidden"); 12 | cy.contains("#5 with transparent png shown in hotpink background"); 13 | cy.contains("#6 with rotation -button displayed"); 14 | cy.contains("#7 with images from external domain"); 15 | 16 | cy.get("img").should("have.length", 7); 17 | }); 18 | 19 | it("can open and close the three first lightboxes", function() { 20 | cy.visit("demo/dist/index.html"); 21 | 22 | cy.get("#react-modal-image-img").should("not.exist"); 23 | 24 | for (let idx of [0, 1, 2]) { 25 | cy.get(`img:nth(${idx})`).click(); 26 | cy.get("#react-modal-image-img"); 27 | 28 | cy.get("span.__react_modal_image__icon_menu").children().last().click(); 29 | cy.get("#react-modal-image-img").should("not.exist"); 30 | } 31 | }); 32 | 33 | it("can zoom in and out with the three first lightboxes", function() { 34 | cy.visit("demo/dist/index.html"); 35 | 36 | for (let idx of [0, 1, 2]) { 37 | cy.get(`img:nth(${idx})`).click(); 38 | 39 | cy.get("span.__react_modal_image__icon_menu").children().first().next().then($zoom1 => { 40 | const initialHtml = $zoom1.html(); 41 | 42 | // doubleclicks 43 | cy.get("#react-modal-image-img").dblclick(); 44 | 45 | cy.get("span.__react_modal_image__icon_menu").children().first().next().then($zoom2 => { 46 | expect($zoom2.html()).to.not.equal(initialHtml); 47 | }); 48 | 49 | cy.get("#react-modal-image-img").dblclick(); 50 | 51 | cy.get("span.__react_modal_image__icon_menu").children().first().next().then($zoom2 => { 52 | expect($zoom2.html()).to.equal(initialHtml); 53 | }); 54 | 55 | // clicks to zoom icon 56 | cy.get("span.__react_modal_image__icon_menu").children().first().next().click(); 57 | 58 | cy.get("span.__react_modal_image__icon_menu").children().first().next().then($zoom2 => { 59 | expect($zoom2.html()).to.not.equal(initialHtml); 60 | }); 61 | 62 | cy.get("span.__react_modal_image__icon_menu").children().first().next().click(); 63 | 64 | cy.get("span.__react_modal_image__icon_menu").children().first().next().then($zoom2 => { 65 | expect($zoom2.html()).to.equal(initialHtml); 66 | }); 67 | }); 68 | 69 | cy.get("span.__react_modal_image__icon_menu").children().last().click(); 70 | } 71 | }); 72 | 73 | it("can download img from the first three lightboxes", function() { 74 | cy.visit("demo/dist/index.html"); 75 | 76 | cy.get("#react-modal-image-img").should("not.exist"); 77 | 78 | for (let idx of [0, 1, 2]) { 79 | cy.get(`img:nth(${idx})`).click(); 80 | cy.get("#react-modal-image-img"); 81 | cy.get("span.__react_modal_image__icon_menu").children().first().should("have.attr", "download"); 82 | 83 | const hrefUrl = 84 | idx < 2 ? "example_img_large.jpg" : "example_img_medium.jpg"; 85 | 86 | cy 87 | .get("span.__react_modal_image__icon_menu").children().first() 88 | .should("have.attr", "href", hrefUrl); 89 | cy.get("span.__react_modal_image__icon_menu").children().last().click(); 90 | } 91 | }); 92 | 93 | 94 | it("zoom and download buttons are hidden in the fourth lightbox", function() { 95 | cy.visit("demo/dist/index.html"); 96 | 97 | const idx = 3 98 | 99 | cy.get(`img:nth(${idx})`).click(); 100 | 101 | cy.get("span.__react_modal_image__icon_menu").children().should("have.length", "1"); 102 | 103 | cy.get(".react-modal-image-zoom").should("not.exist"); 104 | 105 | cy.get("span.__react_modal_image__icon_menu").children().last().click(); 106 | }); 107 | 108 | it("fifth image has hotpink background", function() { 109 | cy.visit("demo/dist/index.html"); 110 | 111 | const idx = 4 112 | 113 | cy.get(`img:nth(${idx})`).click(); 114 | 115 | cy.get(".__react_modal_image__medium_img").should("have.css", "background-color", "rgb(255, 105, 180)"); 116 | 117 | cy.get("span.__react_modal_image__icon_menu").children().last().click(); 118 | }); 119 | 120 | it("can download from external domain", function() { 121 | cy.visit("demo/dist/index.html"); 122 | 123 | cy.get("#react-modal-image-img").should("not.exist"); 124 | 125 | cy.get(`img:nth(${6})`).click(); 126 | cy.get("#react-modal-image-img"); 127 | 128 | // trigger download 129 | cy.get("span.__react_modal_image__icon_menu").children().first().click(); 130 | 131 | const downloadsFolder = Cypress.config("downloadsFolder"); 132 | cy.readFile(downloadsFolder + "/aaa.png").should("exist"); 133 | 134 | // close 135 | cy.get("span.__react_modal_image__icon_menu").children().last().click(); 136 | }) 137 | }); 138 | -------------------------------------------------------------------------------- /cypress/fixtures/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aautio/react-modal-image/83e375e26ab8f520f7af57962ea5ab522f45a70c/cypress/fixtures/.gitkeep -------------------------------------------------------------------------------- /demo/public/example_img_large.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aautio/react-modal-image/83e375e26ab8f520f7af57962ea5ab522f45a70c/demo/public/example_img_large.jpg -------------------------------------------------------------------------------- /demo/public/example_img_medium.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aautio/react-modal-image/83e375e26ab8f520f7af57962ea5ab522f45a70c/demo/public/example_img_medium.jpg -------------------------------------------------------------------------------- /demo/public/example_img_small.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aautio/react-modal-image/83e375e26ab8f520f7af57962ea5ab522f45a70c/demo/public/example_img_small.jpg -------------------------------------------------------------------------------- /demo/public/example_transparent_heart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aautio/react-modal-image/83e375e26ab8f520f7af57962ea5ab522f45a70c/demo/public/example_transparent_heart.png -------------------------------------------------------------------------------- /demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-modal-image live demo 5 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /demo/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import ModalImage from "../../src"; 5 | 6 | import pkg from "../../package.json" 7 | 8 | const Demo = () => ( 9 |
10 |

Demo of react-modal-image@{pkg.version}

11 | 12 |

#1 with alt, small, medium and large props

13 | 14 |
15 | 21 |
22 |

^ click or inspect the image above

23 | 24 |

#2 with small and large props defined only

25 | 26 |
27 | 31 |
32 |

^ click or inspect the image above

33 | 34 |

#3 with small and medium props defined only

35 | 36 |
37 | 41 |
42 |

^ click or inspect the image above

43 | 44 |

#4 with download and zoom -buttons hidden

45 | 46 |
47 | 53 |
54 |

^ click or inspect the image above

55 | 56 |

#5 with transparent png shown in hotpink background

57 | 58 |
59 | 66 |
67 |

^ click or inspect the image above

68 | 69 |

#6 with rotation -button displayed

70 | 71 |
72 | 77 |
78 |

^ click or inspect the image above

79 | 80 |

#7 with images from external domain

81 | 82 |
83 | 87 |
88 | 89 |

Further info

90 | 91 |

92 | Github 93 |

94 |
95 | ); 96 | 97 | // @ts-ignore 98 | const root = createRoot(document.querySelector("#demo")); 99 | root.render(); 100 | -------------------------------------------------------------------------------- /demo/webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebPackPlugin = require("html-webpack-plugin"); 2 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 3 | 4 | module.exports = { 5 | module: { 6 | rules: [ 7 | { 8 | test: /\.(js|jsx)$/, 9 | exclude: /node_modules/, 10 | use: { 11 | loader: "babel-loader", 12 | options: { 13 | presets: ["@babel/preset-env", "@babel/preset-react"], 14 | plugins: ["@babel/plugin-proposal-class-properties"] 15 | } 16 | } 17 | }, 18 | { 19 | test: /\.html$/, 20 | use: [ 21 | { 22 | loader: "html-loader" 23 | } 24 | ] 25 | } 26 | ] 27 | }, 28 | plugins: [ 29 | new HtmlWebPackPlugin({ 30 | template: "./src/index.html", 31 | filename: "./index.html" 32 | }), 33 | new CopyWebpackPlugin({ patterns: [{ from: "./public/" }] }) 34 | ] 35 | }; 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-modal-image", 3 | "version": "2.6.0", 4 | "description": "Lightweight Lightbox React Component", 5 | "main": "lib/index.js", 6 | "module": "es/index.js", 7 | "files": [ 8 | "es", 9 | "lib" 10 | ], 11 | "scripts": { 12 | "build-demo": "cd demo && webpack --mode production", 13 | "start-demo": "cd demo && webpack-dev-server --open --mode development", 14 | "build-component": "rm -rf lib es && MODULES=commonjs babel --config-file ./babel-build-config.js src --out-dir lib && MODULES=es6 babel --config-file ./babel-build-config.js src --out-dir es", 15 | "build": "npm run build-component && npm run build-demo", 16 | "clean": "rm -rf lib es demo/dist", 17 | "cypress:open": "cypress open", 18 | "cypress:run": "cypress run" 19 | }, 20 | "devDependencies": { 21 | "@babel/cli": "^7.6.2", 22 | "@babel/core": "^7.6.2", 23 | "@babel/plugin-proposal-class-properties": "^7.5.5", 24 | "@babel/preset-env": "^7.6.2", 25 | "@babel/preset-react": "^7.0.0", 26 | "babel-loader": "^8.0.6", 27 | "copy-webpack-plugin": "^11.0.0", 28 | "cypress": "^10.7.0", 29 | "html-loader": "^4.1.0", 30 | "html-webpack-plugin": "^5.5.0", 31 | "react": "^18", 32 | "react-dom": "^18", 33 | "webpack": "^5.74.0", 34 | "webpack-cli": "^4.10.0", 35 | "webpack-dev-server": "^4.10.1" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "https://github.com/aautio/react-modal-image" 40 | }, 41 | "author": "Ari Autio ", 42 | "license": "MIT", 43 | "keywords": [ 44 | "react-component", 45 | "lightbox", 46 | "modal", 47 | "image", 48 | "react" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /src/Header.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { ZoomInIcon, ZoomOutIcon, DownloadIcon, CloseIcon, RotateIcon } from "./icons"; 4 | 5 | function isSameOrigin(href) { 6 | // @ts-ignore 7 | return document.location.hostname !== new URL(href, document.location).hostname 8 | } 9 | 10 | /** 11 | * Triggers image download from cross origin URLs 12 | * 13 | * `foo works only for same-origin URLs. 14 | * Further info: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attr-download 15 | */ 16 | 17 | const crossOriginDownload = href => event => { 18 | if (!isSameOrigin(href)) { 19 | // native download will be triggered by `download` attribute 20 | return 21 | } 22 | 23 | // else proceed to use `fetch` for cross origin image download 24 | 25 | event.preventDefault(); 26 | 27 | fetch(href) 28 | .then(res => { 29 | if (!res.ok) { 30 | console.error("Failed to download image, HTTP status " + res.status + " from " + href) 31 | } 32 | 33 | return res.blob().then(blob => { 34 | let tmpAnchor = document.createElement("a") 35 | tmpAnchor.setAttribute("download", href.split("/").pop()) 36 | tmpAnchor.href = URL.createObjectURL(blob) 37 | tmpAnchor.click() 38 | }) 39 | }) 40 | .catch(err => { 41 | console.error(err) 42 | console.error("Failed to download image from " + href) 43 | }) 44 | }; 45 | 46 | 47 | const Header = ({ 48 | image, 49 | alt, 50 | zoomed, 51 | toggleZoom, 52 | toggleRotate, 53 | onClose, 54 | enableDownload, 55 | enableZoom, 56 | enableRotate 57 | }) => ( 58 |
59 | 60 | {enableDownload && ( 61 | 62 | 63 | 64 | )} 65 | {enableZoom && ( 66 | 67 | {zoomed ? : } 68 | 69 | )} 70 | {enableRotate && ( 71 | 72 | 73 | 74 | )} 75 | 76 | 77 | 78 | 79 | {alt && {alt}} 80 |
81 | ); 82 | 83 | export default Header; 84 | -------------------------------------------------------------------------------- /src/Image.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import { SpinnerIcon } from "./icons"; 4 | 5 | export default class Image extends Component { 6 | state = { 7 | loading: true 8 | }; 9 | 10 | handleOnLoad = () => { 11 | this.setState({ loading: false }); 12 | }; 13 | 14 | handleOnContextMenu = event => { 15 | !this.props.contextMenu && event.preventDefault(); 16 | }; 17 | 18 | render() { 19 | const { id, className, src, style, handleDoubleClick } = this.props; 20 | 21 | return ( 22 |
23 | {this.state.loading && } 24 | 33 |
34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Lightbox.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import StyleInjector, { lightboxStyles } from "./styles"; 4 | 5 | import Header from "./Header"; 6 | import Image from "./Image"; 7 | 8 | export default class Lightbox extends Component { 9 | state = { 10 | move: { x: 0, y: 0 }, 11 | moveStart: undefined, 12 | zoomed: false, 13 | rotationDeg: 0 14 | }; 15 | 16 | handleKeyDown = event => { 17 | // ESC or ENTER closes the modal 18 | if (event.keyCode === 27 || event.keyCode === 13) { 19 | this.props.onClose(); 20 | } 21 | }; 22 | 23 | componentDidMount() { 24 | document.addEventListener("keydown", this.handleKeyDown, false); 25 | } 26 | 27 | componentWillUnmount() { 28 | document.removeEventListener("keydown", this.handleKeyDown, false); 29 | } 30 | 31 | getCoordinatesIfOverImg = event => { 32 | const point = event.changedTouches ? event.changedTouches[0] : event; 33 | 34 | if (point.target.id !== "react-modal-image-img") { 35 | // the img was not a target of the coordinates 36 | return; 37 | } 38 | 39 | const dim = this.contentEl.getBoundingClientRect(); 40 | const x = point.clientX - dim.left; 41 | const y = point.clientY - dim.top; 42 | 43 | return { x, y }; 44 | }; 45 | 46 | handleMouseDownOrTouchStart = event => { 47 | event.preventDefault(); 48 | 49 | if (event.touches && event.touches.length > 1) { 50 | // more than one finger, ignored 51 | return; 52 | } 53 | 54 | const coords = this.getCoordinatesIfOverImg(event); 55 | 56 | if (!coords) { 57 | // click outside the img => close modal 58 | this.props.onClose(); 59 | } 60 | 61 | if (!this.state.zoomed) { 62 | // do not allow drag'n'drop if zoom has not been applied 63 | return; 64 | } 65 | 66 | this.setState(prev => { 67 | return { 68 | moveStart: { 69 | x: coords.x - prev.move.x, 70 | y: coords.y - prev.move.y 71 | } 72 | }; 73 | }); 74 | }; 75 | 76 | handleMouseMoveOrTouchMove = event => { 77 | event.preventDefault(); 78 | 79 | if (!this.state.zoomed || !this.state.moveStart) { 80 | // do not allow drag'n'drop if zoom has not been applied 81 | // or if there has not been a click 82 | return; 83 | } 84 | 85 | if (event.touches && event.touches.length > 1) { 86 | // more than one finger, ignored 87 | return; 88 | } 89 | 90 | const coords = this.getCoordinatesIfOverImg(event); 91 | 92 | if (!coords) { 93 | return; 94 | } 95 | 96 | this.setState(prev => { 97 | return { 98 | move: { 99 | x: coords.x - prev.moveStart.x, 100 | y: coords.y - prev.moveStart.y 101 | } 102 | }; 103 | }); 104 | }; 105 | 106 | handleMouseUpOrTouchEnd = event => { 107 | this.setState({ 108 | moveStart: undefined 109 | }); 110 | }; 111 | 112 | toggleZoom = event => { 113 | event.preventDefault(); 114 | this.setState(prev => ({ 115 | zoomed: !prev.zoomed, 116 | // reset position if zoomed out 117 | move: prev.zoomed ? { x: 0, y: 0 } : prev.move 118 | })); 119 | }; 120 | 121 | toggleRotate = event => { 122 | event.preventDefault(); 123 | 124 | const { rotationDeg } = this.state; 125 | 126 | if (rotationDeg === 360) { 127 | this.setState({ rotationDeg: 90 }); 128 | return; 129 | } 130 | 131 | this.setState(prevState => ({ 132 | rotationDeg: (prevState.rotationDeg += 90) 133 | })); 134 | }; 135 | 136 | render() { 137 | const { 138 | medium, 139 | large, 140 | alt, 141 | onClose, 142 | hideDownload, 143 | hideZoom, 144 | showRotate, 145 | imageBackgroundColor = "black" 146 | } = this.props; 147 | const { move, zoomed, rotationDeg } = this.state; 148 | 149 | return ( 150 |
151 | 155 | 156 |
157 |
{ 166 | this.contentEl = el; 167 | }} 168 | > 169 | {zoomed && ( 170 | 187 | )} 188 | {!zoomed && ( 189 | 201 | )} 202 |
203 | 204 |
215 |
216 |
217 | ); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/icons.js: -------------------------------------------------------------------------------- 1 | /* 2 | Icons from https://material.io/icons/ 3 | */ 4 | 5 | import React from "react"; 6 | 7 | export const ZoomInIcon = () => ( 8 | 15 | 16 | 17 | 18 | ); 19 | 20 | export const ZoomOutIcon = () => ( 21 | 28 | 29 | 30 | 31 | ); 32 | 33 | export const DownloadIcon = () => ( 34 | 41 | 42 | 43 | 44 | ); 45 | 46 | export const CloseIcon = () => ( 47 | 54 | 55 | 56 | 57 | ); 58 | 59 | export const SpinnerIcon = () => ( 60 | 67 | 68 | 69 | 70 | ); 71 | 72 | export const RotateIcon = () => ( 73 | 80 | 81 | 82 | 83 | 84 | ) 85 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | 3 | import Lightbox from "./Lightbox"; 4 | 5 | export { default as Lightbox } from "./Lightbox"; 6 | 7 | export default class extends Component { 8 | state = { modalOpen: false }; 9 | 10 | toggleModal = () => { 11 | this.setState(prev => ({ 12 | modalOpen: !prev.modalOpen 13 | })); 14 | }; 15 | 16 | render() { 17 | const { 18 | className, 19 | small, 20 | smallSrcSet, 21 | medium, 22 | large, 23 | alt, 24 | hideDownload, 25 | hideZoom, 26 | showRotate, 27 | imageBackgroundColor 28 | } = this.props; 29 | const { modalOpen } = this.state; 30 | 31 | return ( 32 |
33 | {alt} 45 | {modalOpen && ( 46 | 56 | )} 57 |
58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/styles.js: -------------------------------------------------------------------------------- 1 | import { Component } from "react"; 2 | 3 | function appendStyle(id, css) { 4 | if (!document.head.querySelector("#" + id)) { 5 | const node = document.createElement("style"); 6 | node.textContent = css; 7 | node.type = "text/css"; 8 | node.id = id; 9 | 10 | document.head.appendChild(node); 11 | } 12 | } 13 | 14 | export default class StyleInjector extends Component { 15 | componentDidMount() { 16 | appendStyle(this.props.name, this.props.css); 17 | } 18 | 19 | componentWillUnmount() { 20 | const node = document.getElementById(this.props.name); 21 | node.parentNode.removeChild(node); 22 | } 23 | 24 | render() { 25 | return null; 26 | } 27 | } 28 | 29 | export const lightboxStyles = ({ imageBackgroundColor }) => ` 30 | body { 31 | overflow: hidden; 32 | } 33 | 34 | .__react_modal_image__modal_container { 35 | position: fixed; 36 | z-index: 5000; 37 | left: 0; 38 | top: 0; 39 | width: 100%; 40 | height: 100%; 41 | background-color: rgba(0, 0, 0, 0.8); 42 | touch-action: none; 43 | overflow: hidden; 44 | } 45 | 46 | .__react_modal_image__modal_content { 47 | position: relative; 48 | height: 100%; 49 | width: 100%; 50 | } 51 | 52 | .__react_modal_image__modal_content img, 53 | .__react_modal_image__modal_content svg { 54 | position: absolute; 55 | top: 50%; 56 | left: 50%; 57 | transform: translate3d(-50%, -50%, 0); 58 | -webkit-transform: translate3d(-50%, -50%, 0); 59 | -ms-transform: translate3d(-50%, -50%, 0); 60 | overflow: hidden; 61 | } 62 | 63 | .__react_modal_image__medium_img { 64 | max-width: 98%; 65 | max-height: 98%; 66 | background-color: ${imageBackgroundColor}; 67 | } 68 | 69 | .__react_modal_image__large_img { 70 | cursor: move; 71 | background-color: ${imageBackgroundColor} 72 | } 73 | 74 | .__react_modal_image__icon_menu a { 75 | display: inline-block; 76 | font-size: 40px; 77 | cursor: pointer; 78 | line-height: 40px; 79 | box-sizing: border-box; 80 | border: none; 81 | padding: 0px 5px 0px 5px; 82 | margin-left: 10px; 83 | color: white; 84 | background-color: rgba(0, 0, 0, 0); 85 | } 86 | 87 | .__react_modal_image__icon_menu { 88 | display: inline-block; 89 | float: right; 90 | } 91 | 92 | .__react_modal_image__caption { 93 | display: inline-block; 94 | color: white; 95 | font-size: 120%; 96 | padding: 10px; 97 | margin: 0; 98 | } 99 | 100 | .__react_modal_image__header { 101 | position: absolute; 102 | top: 0; 103 | width: 100%; 104 | background-color: rgba(0, 0, 0, 0.7); 105 | overflow: hidden; 106 | } 107 | `; 108 | --------------------------------------------------------------------------------