├── .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 |
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 |
18 | );
19 |
20 | export const ZoomOutIcon = () => (
21 |
31 | );
32 |
33 | export const DownloadIcon = () => (
34 |
44 | );
45 |
46 | export const CloseIcon = () => (
47 |
57 | );
58 |
59 | export const SpinnerIcon = () => (
60 |
70 | );
71 |
72 | export const RotateIcon = () => (
73 |
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 |

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 |
--------------------------------------------------------------------------------