├── jest-setup.ts ├── .browserslistrc ├── .editorconfig ├── .vscode └── settings.json ├── tsconfig.buildtypes.json ├── src ├── components │ ├── index.tsx │ ├── ImageStage │ │ ├── utils │ │ │ ├── index.ts │ │ │ ├── imageIsOutOfBounds.ts │ │ │ ├── useDoubleClick.tsx │ │ │ ├── getTranslateOffsetsFromScale.ts │ │ │ └── useRefSize.tsx │ │ ├── components │ │ │ ├── SSRImagePager │ │ │ │ └── SSRImagePager.tsx │ │ │ ├── ImagePager │ │ │ │ └── index.tsx │ │ │ └── Image │ │ │ │ └── index.tsx │ │ └── index.tsx │ ├── CreatePortal │ │ └── index.tsx │ └── PageContainer │ │ └── index.tsx ├── types │ └── ImagesList.ts ├── __tests__ │ ├── components │ │ └── SimpleLightbox.tsx │ └── lightbox.test.tsx └── index.tsx ├── example ├── next-env.d.ts ├── next.config.js ├── README.md ├── tsconfig.json ├── components │ ├── GalleryLightbox │ │ ├── components │ │ │ ├── LightboxButtonControl.jsx │ │ │ ├── LightboxArrowButton.jsx │ │ │ ├── GridImage.jsx │ │ │ └── LightboxHeader.jsx │ │ └── index.jsx │ └── InlineLightbox │ │ └── index.jsx ├── pages │ ├── _document.jsx │ ├── _app.jsx │ └── index.tsx ├── package.json └── yarn.lock ├── .prettierignore ├── .prettierrc.js ├── .babelrc.js ├── .gitignore ├── .travis.yml ├── .eslintignore ├── jest.config.js ├── tsconfig.json ├── rollup.config.mjs ├── LICENSE ├── .eslintrc.js ├── package.json ├── CHANGELOG.md └── README.md /jest-setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 0.5%, last 2 versions, Firefox ESR, not dead 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "javascript.validate.enable": false, 4 | "editor.tabSize": 4, 5 | "editor.detectIndentation": false, 6 | "eslint.enable": true 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.buildtypes.json: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "include": ["src/index.tsx"], 5 | "exclude": ["node_modules", "dist", "example", "src/__tests__"] 6 | } 7 | -------------------------------------------------------------------------------- /src/components/index.tsx: -------------------------------------------------------------------------------- 1 | import ImageStage from './ImageStage'; 2 | import PageContainer from './PageContainer'; 3 | import CreatePortal from './CreatePortal'; 4 | 5 | export { ImageStage, PageContainer, CreatePortal }; 6 | -------------------------------------------------------------------------------- /example/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | .next/** 3 | dist/** 4 | coverage/** 5 | example/node_modules/** 6 | example/.next/** 7 | yarn.lock 8 | yarn-error.log 9 | .editorconfig 10 | .eslintignore 11 | .gitignore 12 | .prettierignore 13 | .browserslistrc 14 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configure Prettier 3 | * 4 | * https://prettier.io/docs/en/configuration.html#basic-configuration 5 | */ 6 | module.exports = { 7 | endOfLine: 'auto', 8 | semi: true, 9 | singleQuote: true, 10 | tabWidth: 4, 11 | }; 12 | -------------------------------------------------------------------------------- /src/types/ImagesList.ts: -------------------------------------------------------------------------------- 1 | export type ImagesListItem = Omit< 2 | React.HTMLProps, 3 | 'draggable' | 'onClick' | 'onDragStart' | 'ref' 4 | > & { alt: string; loading?: 'auto' | 'eager' | 'lazy'; src: string }; 5 | 6 | export type ImagesList = ImagesListItem[]; 7 | -------------------------------------------------------------------------------- /example/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | compiler: { 4 | styledComponents: true, 5 | }, 6 | transpilePackages: ['styled-components', 'react-spring-lightbox'], 7 | }; 8 | 9 | module.exports = nextConfig; 10 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # react-spring-lightbox demo app 2 | 3 | This directory contains a Next.js app useful for testing and developing `react-spring-lightbox` 4 | 5 | Install dependencies 6 | 7 | ```bash 8 | yarn install 9 | ``` 10 | 11 | Run locally with hot module reloading 12 | 13 | ```bash 14 | yarn dev 15 | ``` 16 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | ['@babel/plugin-transform-class-properties'], 4 | ['@babel/plugin-transform-object-rest-spread'], 5 | ['@babel/plugin-transform-runtime', { regenerator: false }], 6 | ], 7 | presets: ['@babel/env', '@babel/react', '@babel/preset-typescript'], 8 | }; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | coverage 9 | build 10 | dist 11 | .rpt2_cache 12 | .next 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /src/components/ImageStage/utils/index.ts: -------------------------------------------------------------------------------- 1 | import getTranslateOffsetsFromScale from './getTranslateOffsetsFromScale'; 2 | import imageIsOutOfBounds from './imageIsOutOfBounds'; 3 | import useDoubleClick from './useDoubleClick'; 4 | import useWindowSize from './useRefSize'; 5 | 6 | export { 7 | getTranslateOffsetsFromScale, 8 | imageIsOutOfBounds, 9 | useDoubleClick, 10 | useWindowSize, 11 | }; 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'lts/*' 4 | cache: 5 | yarn: true 6 | directories: 7 | - node_modules 8 | install: yarn install 9 | jobs: 10 | include: 11 | - stage: lint 12 | script: yarn lint 13 | - stage: test 14 | script: yarn test 15 | - stage: build 16 | script: yarn build 17 | branches: 18 | only: master 19 | notifications: 20 | email: false 21 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | example/node_modules/** 6 | 7 | # production 8 | /build 9 | /dist 10 | /coverage 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | npm-debug.log* 16 | yarn-debug.log* 17 | yarn-error.log* 18 | yarn.lock* 19 | package.json* 20 | CHANGELOG.md* 21 | README.md* 22 | 23 | example/.DS_Store 24 | example/.env 25 | example/npm-debug.log* 26 | example/yarn-debug.log* 27 | example/yarn-error.log* 28 | example/yarn.lock* 29 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "esModuleInterop": true, 5 | "incremental": true, 6 | "isolatedModules": true, 7 | "jsx": "preserve", 8 | "lib": ["dom", "dom.iterable", "esnext"], 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "noEmit": true, 12 | "resolveJsonModule": true, 13 | "skipLibCheck": true, 14 | "strict": false 15 | }, 16 | "exclude": ["node_modules"], 17 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] 18 | } 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configure Jest as the test runner for @testing-library 3 | * 4 | * @see https://jestjs.io/docs/en/configuration 5 | */ 6 | module.exports = { 7 | collectCoverage: true, 8 | coveragePathIgnorePatterns: ['/node_modules/', '/__tests__/'], 9 | preset: 'ts-jest', 10 | setupFilesAfterEnv: ['babel-polyfill', '/jest-setup.ts'], 11 | testEnvironment: 'jsdom', 12 | testMatch: ['**/__tests__/**/*.(spec|test).[jt]s?(x)'], 13 | transform: { 14 | '^.+\\.(js|jsx)$': 'babel-jest', 15 | '^.+\\.(ts|tsx)?$': 'ts-jest', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /example/components/GalleryLightbox/components/LightboxButtonControl.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default styled.button` 4 | z-index: 10; 5 | background: none; 6 | border-style: none; 7 | font-size: 50px; 8 | cursor: pointer; 9 | padding: 0; 10 | margin: 0; 11 | color: ${({ theme }) => theme.pageContentFontColor}; 12 | transition: color 0.2s linear; 13 | :hover { 14 | color: ${({ theme }) => theme.pageContentLinkHoverColor}; 15 | } 16 | :focus { 17 | outline: none; 18 | color: ${({ theme }) => theme.pageContentLinkHoverColor}; 19 | } 20 | `; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | { 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "moduleResolution": "node", 8 | "jsx": "react", 9 | "sourceMap": true, 10 | "declaration": true, 11 | "esModuleInterop": true, 12 | "noImplicitReturns": true, 13 | "noImplicitThis": true, 14 | "noImplicitAny": true, 15 | "strictNullChecks": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "allowSyntheticDefaultImports": true, 19 | "rootDir": "src", 20 | "target": "ES5", 21 | "isolatedModules": true, 22 | "skipLibCheck": true 23 | }, 24 | "include": ["src"], 25 | "exclude": ["node_modules", "dist", "example", "src/__tests__"], 26 | "types": ["node", "jest", "@testing-library/jest-dom"] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ImageStage/utils/imageIsOutOfBounds.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Determines if the provided image is within the viewport 3 | * 4 | * @returns True if image needs to be resized to fit viewport, otherwise false 5 | */ 6 | const imageIsOutOfBounds = ( 7 | imageRef: React.RefObject, 8 | ): boolean => { 9 | // If no ref is provided, return false 10 | if (!imageRef.current) { 11 | return false; 12 | } 13 | 14 | const { 15 | bottom: bottomRightY, 16 | left: topLeftX, 17 | right: bottomRightX, 18 | top: topLeftY, 19 | } = imageRef.current?.getBoundingClientRect(); 20 | const { innerHeight: windowHeight, innerWidth: windowWidth } = window; 21 | 22 | if ( 23 | topLeftX > windowWidth * (1 / 2) || 24 | topLeftY > windowHeight * (1 / 2) || 25 | bottomRightX < windowWidth * (1 / 2) || 26 | bottomRightY < windowHeight * (1 / 2) 27 | ) 28 | return true; 29 | 30 | return false; 31 | }; 32 | 33 | export default imageIsOutOfBounds; 34 | -------------------------------------------------------------------------------- /example/pages/_document.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Document from 'next/document'; 3 | import { ServerStyleSheet } from 'styled-components'; 4 | 5 | export default class MyDocument extends Document { 6 | static async getInitialProps(ctx) { 7 | const sheet = new ServerStyleSheet(); 8 | const originalRenderPage = ctx.renderPage; 9 | 10 | try { 11 | ctx.renderPage = () => 12 | originalRenderPage({ 13 | enhanceApp: (App) => (props) => 14 | sheet.collectStyles(), 15 | }); 16 | 17 | const initialProps = await Document.getInitialProps(ctx); 18 | return { 19 | ...initialProps, 20 | styles: ( 21 | <> 22 | {initialProps.styles} 23 | {sheet.getStyleElement()} 24 | 25 | ), 26 | }; 27 | } finally { 28 | sheet.seal(); 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import filesize from 'rollup-plugin-filesize'; 3 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 4 | import { babel } from '@rollup/plugin-babel'; 5 | import terser from '@rollup/plugin-terser'; 6 | import { nodeExternals } from 'rollup-plugin-node-externals'; 7 | 8 | export default { 9 | input: './src/index.tsx', 10 | output: [ 11 | { 12 | exports: 'default', 13 | file: 'dist/index.cjs.js', 14 | format: 'cjs', 15 | interop: 'auto', 16 | sourcemap: true, 17 | }, 18 | ], 19 | plugins: [ 20 | nodeExternals(), 21 | nodeResolve(), 22 | commonjs({ 23 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 24 | include: 'node_modules/**', 25 | }), 26 | babel({ 27 | babelHelpers: 'runtime', 28 | exclude: 'node_modules/**', 29 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 30 | }), 31 | terser(), 32 | filesize(), 33 | ], 34 | }; 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-present Tim Ellenberger 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 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-spring-lightbox-example", 3 | "homepage": "https://tim-soft.github.io/react-spring-lightbox", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "private": true, 7 | "dependencies": { 8 | "@react-spring/web": "link:../node_modules/@react-spring/web", 9 | "color": "^4.2.3", 10 | "lodash.clamp": "^4.0.3", 11 | "lodash.merge": "^4.6.2", 12 | "next": "13.5.4", 13 | "prop-types": "^15.6.2", 14 | "react": "link:../node_modules/react", 15 | "react-dom": "link:../node_modules/react-dom", 16 | "react-icons": "^4.2.0", 17 | "react-is": "link:../node_modules/react-is", 18 | "react-photo-gallery": "^8.0.0", 19 | "react-spring-lightbox": "link:..", 20 | "styled-components": "link:../node_modules/styled-components", 21 | "styled-normalize": "^8.0.7" 22 | }, 23 | "scripts": { 24 | "dev": "next", 25 | "start": "next start", 26 | "build": "next build" 27 | }, 28 | "browserslist": [ 29 | ">0.2%", 30 | "not dead", 31 | "not ie <= 11", 32 | "not op_mini all" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/components/ImageStage/components/SSRImagePager/SSRImagePager.tsx: -------------------------------------------------------------------------------- 1 | import type { ImagesList } from '../../../../types/ImagesList'; 2 | import styled, { css } from 'styled-components'; 3 | import * as React from 'react'; 4 | 5 | type ISSRImagePagerProps = { 6 | currentIndex: number; 7 | images: ImagesList; 8 | }; 9 | 10 | const SSRImagePager = ({ currentIndex, images }: ISSRImagePagerProps) => { 11 | return ( 12 | 13 | {images.map(({ alt, src }, i) => { 14 | return ( 15 | {alt} 21 | ); 22 | })} 23 | 24 | ); 25 | }; 26 | 27 | export default SSRImagePager; 28 | 29 | const ImagePagerContainer = styled.div` 30 | width: 100%; 31 | height: inherit; 32 | `; 33 | 34 | const Image = styled.img<{ $isCurrentImage: boolean }>` 35 | ${({ $isCurrentImage }) => 36 | !$isCurrentImage && 37 | css` 38 | visibility: hidden; 39 | display: none; 40 | `} 41 | height:100%; 42 | width: 100%; 43 | object-fit: contain; 44 | `; 45 | -------------------------------------------------------------------------------- /src/__tests__/components/SimpleLightbox.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import Lightbox, { ImagesListType } from '../../index'; 3 | 4 | const images: ImagesListType = [ 5 | { 6 | alt: 'Windows 10 Dark Mode Setting', 7 | src: 'https://timellenberger.com/static/blog-content/dark-mode/win10-dark-mode.jpg', 8 | }, 9 | { 10 | alt: 'macOS Mojave Dark Mode Setting', 11 | src: 'https://timellenberger.com/static/blog-content/dark-mode/macos-dark-mode.png', 12 | }, 13 | { 14 | alt: 'Android 9.0 Dark Mode Setting', 15 | src: 'https://timellenberger.com/static/blog-content/dark-mode/android-9-dark-mode.jpg', 16 | }, 17 | ]; 18 | 19 | const SimpleLightbox = ( 20 | props: Partial>, 21 | ) => { 22 | const [currentImageIndex, setCurrentIndex] = useState(0); 23 | 24 | const gotoPrevious = () => 25 | currentImageIndex > 0 && setCurrentIndex(currentImageIndex - 1); 26 | 27 | const gotoNext = () => 28 | currentImageIndex + 1 < images.length && 29 | setCurrentIndex(currentImageIndex + 1); 30 | 31 | return ( 32 | null} 37 | onNext={gotoNext} 38 | onPrev={gotoPrevious} 39 | {...props} 40 | /> 41 | ); 42 | }; 43 | 44 | export default SimpleLightbox; 45 | -------------------------------------------------------------------------------- /example/pages/_app.jsx: -------------------------------------------------------------------------------- 1 | import App from 'next/app'; 2 | import React from 'react'; 3 | import { createGlobalStyle, ThemeProvider } from 'styled-components'; 4 | import styledNormalize from 'styled-normalize'; 5 | 6 | export default class MyApp extends App { 7 | render() { 8 | const { Component, pageProps } = this.props; 9 | return ( 10 | <> 11 | {/* Adds some basic body styles */} 12 | 13 | 14 | 24 | 25 | 26 | 27 | ); 28 | } 29 | } 30 | 31 | /** 32 | * Adds global styles and normalize.css to the entire app 33 | * 34 | * http://nicolasgallagher.com/about-normalize-css/ 35 | * https://www.styled-components.com/docs/api#createglobalstyle 36 | */ 37 | const DefaultStyles = createGlobalStyle` 38 | ${styledNormalize} 39 | body { 40 | margin: 0; 41 | background: #1D1E1F; 42 | font-family: 'Montserrat', sans-serif; 43 | -ms-text-size-adjust: 100%; 44 | -webkit-text-size-adjust: 100%; 45 | -moz-osx-font-smoothing: grayscale; 46 | -webkit-font-smoothing: antialiased; 47 | } 48 | `; 49 | -------------------------------------------------------------------------------- /example/components/GalleryLightbox/components/LightboxArrowButton.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-shadow */ 2 | import * as React from 'react'; 3 | import PropTypes from 'prop-types'; 4 | import styled from 'styled-components'; 5 | import { IoIosArrowBack, IoIosArrowForward } from 'react-icons/io'; 6 | import { animated, useTransition } from '@react-spring/web'; 7 | import ButtonControl from './LightboxButtonControl'; 8 | 9 | const ArrowButton = ({ className, disabled, onClick, position }) => { 10 | const transitions = useTransition(!disabled, { 11 | enter: { opacity: 1 }, 12 | from: { opacity: 0 }, 13 | leave: { opacity: 0 }, 14 | }); 15 | 16 | return transitions( 17 | (props, item) => 18 | item && ( 19 | 20 | 24 | 25 | ), 26 | ); 27 | }; 28 | 29 | ArrowButton.propTypes = { 30 | disabled: PropTypes.bool, 31 | onClick: PropTypes.func.isRequired, 32 | position: PropTypes.oneOf(['left', 'right']).isRequired, 33 | }; 34 | 35 | ArrowButton.defaultProps = { 36 | disabled: false, 37 | }; 38 | 39 | export default ArrowButton; 40 | 41 | const StyledAnimatedDiv = styled(animated.div)` 42 | z-index: 999; 43 | `; 44 | 45 | const Button = styled(ButtonControl)` 46 | position: absolute; 47 | top: 0; 48 | bottom: 0; 49 | left: ${({ position }) => (position === 'left' ? 0 : 'unset')}; 50 | right: ${({ position }) => (position === 'right' ? 0 : 'unset')}; 51 | `; 52 | -------------------------------------------------------------------------------- /src/components/ImageStage/utils/useDoubleClick.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | type IUseDoubleClickProps = { 4 | /** Set to false to disable onDoubleClick/onSingleClick */ 5 | enabled?: boolean; 6 | /** The amount of time (in milliseconds) to wait before differentiating a single from a double click */ 7 | latency?: number; 8 | /** A callback function for double click events */ 9 | onDoubleClick?: (event: MouseEvent) => void; 10 | /** A callback function for single click events */ 11 | onSingleClick?: (event: MouseEvent) => void; 12 | /** Dom node to watch for double clicks */ 13 | ref: React.RefObject; 14 | }; 15 | 16 | /** 17 | * React Hook that returns the current window size 18 | * and report updates from the 'resize' window event 19 | */ 20 | const useDoubleClick = ({ 21 | enabled = true, 22 | latency = 300, 23 | onDoubleClick = () => null, 24 | onSingleClick = () => null, 25 | ref, 26 | }: IUseDoubleClickProps) => { 27 | useEffect(() => { 28 | const clickRef = ref.current; 29 | let clickCount = 0; 30 | let timer: ReturnType; 31 | 32 | const handleClick = (e: MouseEvent) => { 33 | if (enabled) { 34 | clickCount += 1; 35 | 36 | timer = setTimeout(() => { 37 | if (clickCount === 1) onSingleClick(e); 38 | else if (clickCount === 2) onDoubleClick(e); 39 | 40 | clickCount = 0; 41 | }, latency); 42 | } 43 | }; 44 | 45 | // Add event listener for click events 46 | clickRef?.addEventListener('click', handleClick); 47 | 48 | // Remove event listener 49 | return () => { 50 | clickRef?.removeEventListener('click', handleClick); 51 | 52 | if (timer) { 53 | clearTimeout(timer); 54 | } 55 | }; 56 | }); 57 | }; 58 | 59 | export default useDoubleClick; 60 | -------------------------------------------------------------------------------- /src/components/CreatePortal/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | type ICreatePortal = { 5 | children: any; 6 | }; 7 | 8 | /** 9 | * Creates a SSR + next.js friendly React Portal inside 10 | * 11 | * Child components are rendered on the client side only 12 | 13 | * @see https://reactjs.org/docs/portals.html 14 | */ 15 | class CreatePortal extends React.Component { 16 | portalContainer: HTMLDivElement; 17 | body: HTMLElement; 18 | 19 | // Only executes on the client-side 20 | componentDidMount() { 21 | // Get the document body 22 | this.body = document.body; 23 | 24 | // Create a container
for React Portal 25 | this.portalContainer = document.createElement('div'); 26 | this.portalContainer.setAttribute('class', 'lightbox-portal'); 27 | 28 | // Append the container to the document body 29 | this.body.appendChild(this.portalContainer); 30 | 31 | // Force a re-render as we're on the client side now 32 | // children prop will render to portalContainer 33 | this.forceUpdate(); 34 | 35 | // Add event listener to prevent trackpad/ctrl+mousewheel zooming of lightbox 36 | // Zooming is handled specifically within /ImageStage/components/Image 37 | this.portalContainer.addEventListener('wheel', this.preventWheel); 38 | } 39 | 40 | componentWillUnmount() { 41 | // Remove wheel event listener 42 | this.portalContainer.removeEventListener('wheel', this.preventWheel); 43 | 44 | // Cleanup Portal from DOM 45 | this.body.removeChild(this.portalContainer); 46 | } 47 | 48 | preventWheel = (e: WheelEvent) => e.preventDefault(); 49 | 50 | render() { 51 | // Return null during SSR 52 | if (this.portalContainer === undefined) return null; 53 | 54 | const { children } = this.props; 55 | 56 | return <>{ReactDOM.createPortal(children, this.portalContainer)}; 57 | } 58 | } 59 | 60 | export default CreatePortal; 61 | -------------------------------------------------------------------------------- /src/components/ImageStage/utils/getTranslateOffsetsFromScale.ts: -------------------------------------------------------------------------------- 1 | type IGetTranslateOffsetsFromScale = { 2 | /** The current [x,y] translate values of image */ 3 | currentTranslate: [translateX: number, translateY: number]; 4 | /** The image dom node used as a reference to calculate translate offsets */ 5 | imageRef: React.RefObject; 6 | /** The amount of change in the new transform scale */ 7 | pinchDelta: number; 8 | /** The current transform scale of image */ 9 | scale: number; 10 | /** The [x,y] coordinates of the zoom origin */ 11 | touchOrigin: [touchOriginX: number, touchOriginY: number]; 12 | }; 13 | 14 | type ITranslateOffsetsReturnType = [translateX: number, translateY: number]; 15 | 16 | /** 17 | * Calculates the the translate(x,y) coordinates needed to zoom-in 18 | * to a point in an image. 19 | * 20 | * @returns {array} The next [x,y] translate values to apply to image 21 | */ 22 | const getTranslateOffsetsFromScale = ({ 23 | currentTranslate: [translateX, translateY], 24 | imageRef, 25 | pinchDelta, 26 | scale, 27 | touchOrigin: [touchOriginX, touchOriginY], 28 | }: IGetTranslateOffsetsFromScale): ITranslateOffsetsReturnType => { 29 | if (!imageRef?.current) { 30 | return [0, 0]; 31 | } 32 | 33 | const { 34 | height: imageHeight, 35 | left: imageTopLeftX, 36 | top: imageTopLeftY, 37 | width: imageWidth, 38 | } = imageRef.current?.getBoundingClientRect(); 39 | 40 | // Get the (x,y) touch position relative to image origin at the current scale 41 | const imageCoordX = (touchOriginX - imageTopLeftX - imageWidth / 2) / scale; 42 | const imageCoordY = 43 | (touchOriginY - imageTopLeftY - imageHeight / 2) / scale; 44 | 45 | // Calculate translateX/Y offset at the next scale to zoom to touch position 46 | const newTranslateX = -imageCoordX * pinchDelta + translateX; 47 | const newTranslateY = -imageCoordY * pinchDelta + translateY; 48 | 49 | return [newTranslateX, newTranslateY]; 50 | }; 51 | 52 | export default getTranslateOffsetsFromScale; 53 | -------------------------------------------------------------------------------- /src/components/ImageStage/utils/useRefSize.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | 3 | type RefSize = { 4 | height: number; 5 | width: number; 6 | }; 7 | 8 | type Node = HTMLDivElement | null; 9 | 10 | type IUseRefSize = [refSize: RefSize, elementRef: (node: any) => void | null]; 11 | 12 | /** 13 | * React Hook that returns the current ref size 14 | * and report updates from the 'resize' ref event 15 | * 16 | * @returns {RefSize} An object containing the ref width and height 17 | * @returns {elementRef} A callback ref to be used on the container being measured 18 | */ 19 | const useRefSize = (): IUseRefSize => { 20 | const ref = useRef(null); 21 | 22 | const [node, setNode] = useState(null); 23 | const [refSize, setRefSize] = useState({ 24 | height: ref.current?.clientHeight || 0, 25 | width: ref.current?.clientWidth || 0, 26 | }); 27 | 28 | const elementRef = useCallback((node: Node) => { 29 | if (node !== null) { 30 | setNode(node); 31 | 32 | setRefSize({ 33 | height: node.clientHeight, 34 | width: node.clientWidth, 35 | }); 36 | } 37 | }, []); 38 | 39 | useEffect(() => { 40 | const handleResize = () => { 41 | if (node) { 42 | const height = node.clientHeight; 43 | const width = node.clientWidth; 44 | if (height !== refSize.height || width !== refSize.width) { 45 | setRefSize({ 46 | height, 47 | width, 48 | }); 49 | } 50 | } 51 | }; 52 | 53 | window.addEventListener('resize', handleResize); 54 | window.addEventListener('orientationchange', handleResize); 55 | 56 | return () => { 57 | window.removeEventListener('resize', handleResize); 58 | window.removeEventListener('orientationchange', handleResize); 59 | }; 60 | }, [node, refSize.height, refSize.width]); 61 | 62 | return [refSize, elementRef]; 63 | }; 64 | 65 | export default useRefSize; 66 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable sort-keys */ 2 | /** 3 | * Configure ESLint 4 | * 5 | * https://eslint.org/docs/user-guide/configuring 6 | */ 7 | module.exports = { 8 | env: { 9 | browser: true, 10 | es6: true, 11 | jest: true, 12 | }, 13 | extends: [ 14 | 'plugin:react/recommended', 15 | 'plugin:import/warnings', 16 | 'plugin:@typescript-eslint/recommended', 17 | 'plugin:prettier/recommended', 18 | ], 19 | globals: { 20 | document: true, 21 | window: true, 22 | }, 23 | parser: '@typescript-eslint/parser', 24 | parserOptions: { 25 | sourceType: 'module', 26 | }, 27 | plugins: [ 28 | 'prettier', 29 | 'jsx-a11y', 30 | 'react', 31 | 'react-hooks', 32 | 'import', 33 | 'sort-destructure-keys', 34 | '@typescript-eslint', 35 | ], 36 | root: true, 37 | rules: { 38 | 'prettier/prettier': ['error', { endOfLine: 'auto' }], 39 | // Enforce React Hooks rules 40 | // https://www.npmjs.com/package/eslint-plugin-react-hooks 41 | 'react-hooks/rules-of-hooks': 'error', 42 | 'react-hooks/exhaustive-deps': 'warn', 43 | 44 | 'sort-destructure-keys/sort-destructure-keys': [ 45 | 'error', 46 | { caseSensitive: false }, 47 | ], 48 | 'sort-keys': ['error', 'asc', { caseSensitive: false, natural: false }], 49 | 'sort-vars': [ 50 | 'error', 51 | { 52 | ignoreCase: true, 53 | }, 54 | ], 55 | 'react/jsx-sort-props': ['error', { ignoreCase: true }], 56 | '@typescript-eslint/ban-ts-comment': 'off', 57 | '@typescript-eslint/no-explicit-any': 'off', 58 | '@typescript-eslint/explicit-module-boundary-types': 'off', 59 | '@typescript-eslint/member-ordering': [ 60 | 'error', 61 | { 62 | default: { 63 | order: 'alphabetically', 64 | }, 65 | classes: { 66 | order: 'as-written', 67 | }, 68 | }, 69 | ], 70 | }, 71 | settings: { 72 | 'import/resolver': { 73 | node: true, 74 | 'eslint-import-resolver-typescript': true, 75 | }, 76 | react: { 77 | version: 'detect', 78 | }, 79 | }, 80 | }; 81 | -------------------------------------------------------------------------------- /src/components/PageContainer/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useTransition, animated, config } from '@react-spring/web'; 3 | import styled, { AnyStyledComponent } from 'styled-components'; 4 | 5 | type IPageContainerProps = { 6 | /** All child components of Lightbox */ 7 | children: React.ReactNode[]; 8 | /** Classes are applied to the root lightbox component */ 9 | className: string; 10 | /** Flag that dictates if the lightbox is open or closed */ 11 | isOpen: boolean; 12 | /** React-Spring useTransition config for page open/close animation */ 13 | pageTransitionConfig: any; 14 | /** Inline styles are applied to the root lightbox component */ 15 | style: React.CSSProperties; 16 | }; 17 | 18 | /** 19 | * Animates the lightbox as it opens/closes 20 | */ 21 | const PageContainer = ({ 22 | children, 23 | className, 24 | isOpen, 25 | pageTransitionConfig, 26 | style, 27 | }: IPageContainerProps) => { 28 | const defaultTransition = { 29 | config: { ...config.default, friction: 32, mass: 1, tension: 320 }, 30 | enter: { opacity: 1, transform: 'scale(1)' }, 31 | from: { opacity: 0, transform: 'scale(0.75)' }, 32 | leave: { opacity: 0, transform: 'scale(0.75)' }, 33 | }; 34 | 35 | const transitions = useTransition(isOpen, { 36 | ...defaultTransition, 37 | ...pageTransitionConfig, 38 | }); 39 | 40 | return ( 41 | <> 42 | {transitions( 43 | (animatedStyles, item) => 44 | item && ( 45 | 52 | {children} 53 | 54 | ), 55 | )} 56 | 57 | ); 58 | }; 59 | 60 | export default PageContainer; 61 | 62 | const AnimatedPageContainer = styled(animated.div as AnyStyledComponent)` 63 | display: flex; 64 | flex-direction: column; 65 | position: fixed; 66 | z-index: 400; 67 | top: 0; 68 | bottom: 0; 69 | left: 0; 70 | right: 0; 71 | `; 72 | -------------------------------------------------------------------------------- /example/components/GalleryLightbox/components/GridImage.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | 5 | /** 6 | * A single image element in a masonry style image grid 7 | */ 8 | const GridImage = ({ index, key, left, onClick, photo, top }) => { 9 | const { alt, caption, height, src, width } = photo; 10 | return ( 11 | onClick(e, { index })} 15 | style={{ height, left, top, width }} 16 | > 17 | 18 | {alt} 19 | 20 |

{caption}

21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | GridImage.propTypes = { 28 | containerHeight: PropTypes.number.isRequired, 29 | index: PropTypes.number.isRequired, 30 | key: PropTypes.string.isRequired, 31 | left: PropTypes.number.isRequired, 32 | onClick: PropTypes.func.isRequired, 33 | photo: PropTypes.shape({ 34 | alt: PropTypes.string.isRequired, 35 | caption: PropTypes.string.isRequired, 36 | height: PropTypes.number.isRequired, 37 | src: PropTypes.string.isRequired, 38 | width: PropTypes.number.isRequired, 39 | }).isRequired, 40 | top: PropTypes.number.isRequired, 41 | }; 42 | 43 | export default GridImage; 44 | 45 | const Caption = styled.div` 46 | position: absolute; 47 | bottom: 0; 48 | width: 100%; 49 | background-color: ${({ theme }) => theme.accentColor}; 50 | color: ${({ theme }) => theme.pageContentLinkHoverColor}; 51 | h4 { 52 | text-align: center; 53 | margin: 1em 0; 54 | } 55 | `; 56 | 57 | const OverlayContainer = styled.div` 58 | position: relative; 59 | height: 100%; 60 | overflow: hidden; 61 | `; 62 | 63 | const ImageContainer = styled.div` 64 | display: block; 65 | position: absolute; 66 | cursor: pointer; 67 | border-width: 2px; 68 | border-color: transparent; 69 | border-style: solid; 70 | :hover { 71 | border-color: ${({ theme }) => theme.pageContentLinkHoverColor}; 72 | } 73 | `; 74 | 75 | const Image = styled.img` 76 | width: inherit; 77 | height: inherit; 78 | position: absolute; 79 | `; 80 | -------------------------------------------------------------------------------- /example/components/InlineLightbox/index.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import Lightbox from 'react-spring-lightbox'; 5 | import LightboxArrowButton from '../GalleryLightbox/components/LightboxArrowButton'; 6 | 7 | const InlineLightbox = ({ images }) => { 8 | const [currentImageIndex, setCurrentImageIndex] = React.useState(0); 9 | const inlineCarouselElement = React.useRef(); 10 | 11 | React.useEffect(() => { 12 | inlineCarouselElement?.current?.addEventListener('wheel', preventWheel); 13 | 14 | setCurrentImageIndex(0); 15 | }, [inlineCarouselElement, images]); 16 | 17 | const preventWheel = (e) => { 18 | e.preventDefault(); 19 | e.stopPropagation(); 20 | return false; 21 | }; 22 | 23 | const canPrev = currentImageIndex > 0; 24 | const canNext = currentImageIndex + 1 < images.length; 25 | 26 | const gotoNext = () => { 27 | canNext ? setCurrentImageIndex(currentImageIndex + 1) : () => null; 28 | }; 29 | 30 | const gotoPrevious = () => { 31 | canPrev ? setCurrentImageIndex(currentImageIndex - 1) : null; 32 | }; 33 | 34 | return ( 35 | 36 | ( 44 | 49 | )} 50 | renderPrevButton={({ canPrev }) => ( 51 | 56 | )} 57 | singleClickToZoom 58 | /> 59 | 60 | ); 61 | }; 62 | 63 | export default InlineLightbox; 64 | 65 | InlineLightbox.propTypes = { 66 | images: PropTypes.arrayOf( 67 | PropTypes.shape({ 68 | alt: PropTypes.string.isRequired, 69 | caption: PropTypes.string.isRequired, 70 | height: PropTypes.number, 71 | src: PropTypes.string.isRequired, 72 | width: PropTypes.number, 73 | }), 74 | ).isRequired, 75 | }; 76 | 77 | const Container = styled.div` 78 | display: inline-flex; 79 | flex-direction: column; 80 | width: 100%; 81 | height: 384px; 82 | overflow: hidden; 83 | `; 84 | 85 | const StyledLightboxArrowButton = styled(LightboxArrowButton)` 86 | z-index: 10; 87 | button { 88 | font-size: 25px; 89 | } 90 | `; 91 | -------------------------------------------------------------------------------- /example/components/GalleryLightbox/components/LightboxHeader.jsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from 'styled-components'; 4 | import { IoIosClose } from 'react-icons/io'; 5 | import Color from 'color'; 6 | import ButtonControl from './LightboxButtonControl'; 7 | 8 | const LightboxHeader = ({ currentIndex, galleryTitle, images, onClose }) => ( 9 | 10 | 11 | {galleryTitle} 12 | 13 | {images[currentIndex].caption} 14 | 15 | 16 | 17 | 18 | 19 | {currentIndex + 1} / {images.length} 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | 28 | LightboxHeader.propTypes = { 29 | currentIndex: PropTypes.number.isRequired, 30 | galleryTitle: PropTypes.string.isRequired, 31 | images: PropTypes.arrayOf( 32 | PropTypes.shape({ 33 | alt: PropTypes.string.isRequired, 34 | caption: PropTypes.string.isRequired, 35 | height: PropTypes.number, 36 | src: PropTypes.string.isRequired, 37 | width: PropTypes.number, 38 | }), 39 | ).isRequired, 40 | onClose: PropTypes.func.isRequired, 41 | }; 42 | 43 | export default LightboxHeader; 44 | 45 | const GalleryHeading = styled.h2` 46 | margin: 0 0 5px 0; 47 | font-weight: normal; 48 | `; 49 | 50 | const GallerySubheading = styled.h4` 51 | margin: 0; 52 | font-weight: normal; 53 | color: ${({ theme }) => theme.pageContentLinkHoverColor}; 54 | `; 55 | 56 | const PageIndicator = styled.span` 57 | white-space: nowrap; 58 | min-width: 60px; 59 | text-align: center; 60 | `; 61 | 62 | const RightSideContainer = styled.div` 63 | width: 117px; 64 | display: flex; 65 | justify-content: space-between; 66 | align-items: center; 67 | `; 68 | 69 | const CloseButton = styled(ButtonControl)` 70 | height: 100%; 71 | display: flex; 72 | border-left-style: solid; 73 | border-left-width: 3px; 74 | border-left-color: ${({ theme }) => theme.headerNavFontColor}; 75 | color: inherit; 76 | `; 77 | 78 | const LeftSideDescriptionContainer = styled.div` 79 | display: flex; 80 | flex-direction: column; 81 | justify-content: center; 82 | border-left-width: 3px; 83 | border-left-color: ${({ theme }) => theme.pageContentLinkHoverColor}; 84 | border-left-style: solid; 85 | padding: 8px 0 8px 10px; 86 | `; 87 | 88 | const TopHeaderBar = styled.header` 89 | z-index: 10; 90 | cursor: auto; 91 | display: flex; 92 | justify-content: space-between; 93 | padding: 10px 2px 10px 20px; 94 | color: ${({ theme }) => theme.headerNavFontColor}; 95 | background-color: ${({ theme }) => 96 | Color(theme.pageBackgroundColor).alpha(0.5).hsl().string()}; 97 | > * { 98 | height: inherit; 99 | } 100 | `; 101 | -------------------------------------------------------------------------------- /src/components/ImageStage/index.tsx: -------------------------------------------------------------------------------- 1 | import ImagePager from './components/ImagePager'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import useRefSize from './utils/useRefSize'; 5 | import type { ImagesList } from '../../types/ImagesList'; 6 | import SSRImagePager from './components/SSRImagePager/SSRImagePager'; 7 | 8 | type IImageStageProps = { 9 | /** classnames are applied to the root ImageStage component */ 10 | className?: string; 11 | /** Index of image in images array that is currently shown */ 12 | currentIndex: number; 13 | /** Array of image objects to be shown in Lightbox */ 14 | images: ImagesList; 15 | /** Affects Width calculation method, depending on whether the Lightbox is Inline or not */ 16 | inline: boolean; 17 | /** Function that closes the Lightbox */ 18 | onClose?: () => void; 19 | /** Function that can be called to disable dragging in the pager */ 20 | onNext: () => void; 21 | /** True if this image is currently shown in pager, otherwise false */ 22 | onPrev: () => void; 23 | /** A React component that renders inside the image stage, useful for making overlays over the image */ 24 | renderImageOverlay: () => React.ReactNode; 25 | /** A React component that is used for next button in image pager */ 26 | renderNextButton: ({ canNext }: { canNext: boolean }) => React.ReactNode; 27 | /** A React component that is used for previous button in image pager */ 28 | renderPrevButton: ({ canPrev }: { canPrev: boolean }) => React.ReactNode; 29 | /** Overrides the default behavior of double clicking causing an image zoom to a single click */ 30 | singleClickToZoom: boolean; 31 | }; 32 | 33 | /** 34 | * Containing element for ImagePager and prev/next button controls 35 | */ 36 | const ImageStage = ({ 37 | className = '', 38 | currentIndex, 39 | images, 40 | inline, 41 | onClose, 42 | onNext, 43 | onPrev, 44 | renderImageOverlay, 45 | renderNextButton, 46 | renderPrevButton, 47 | singleClickToZoom, 48 | }: IImageStageProps) => { 49 | // Extra sanity check that the next/prev image exists before moving to it 50 | const canPrev = currentIndex > 0; 51 | const canNext = currentIndex + 1 < images.length; 52 | 53 | const onNextImage = canNext ? onNext : () => null; 54 | const onPrevImage = canPrev ? onPrev : () => null; 55 | 56 | const [{ height: containerHeight, width: containerWidth }, containerRef] = 57 | useRefSize(); 58 | 59 | return ( 60 | 65 | {renderPrevButton({ canPrev })} 66 | {containerWidth ? ( 67 | 79 | ) : inline ? ( 80 | 81 | ) : null} 82 | {renderNextButton({ canNext })} 83 | 84 | ); 85 | }; 86 | 87 | export default ImageStage; 88 | 89 | const ImageStageContainer = styled.div` 90 | position: relative; 91 | height: 100%; 92 | width: 100%; 93 | display: flex; 94 | justify-content: center; 95 | align-items: center; 96 | `; 97 | -------------------------------------------------------------------------------- /src/__tests__/lightbox.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, fireEvent, screen } from '@testing-library/react'; 3 | import Lightbox from './components/SimpleLightbox'; 4 | 5 | describe('Lightbox', () => { 6 | describe('CreatePortal', () => { 7 | test('creates portal on render', () => { 8 | render(); 9 | const portalEl = document.body.querySelector('.lightbox-portal'); 10 | expect(portalEl).toBeTruthy(); 11 | }); 12 | }); 13 | 14 | describe('renderHeader', () => { 15 | test('renders custom header', () => { 16 | render( 17 |
} 19 | />, 20 | ); 21 | 22 | const lightboxContainer = screen.getByTestId('lightbox-container'); 23 | const lightboxHeader = screen.getByTestId('header'); 24 | 25 | // Lightbox container should have a header and pager body 26 | expect(lightboxContainer.childElementCount).toBe(2); 27 | expect(lightboxContainer).toBeInTheDocument(); 28 | 29 | // Header exists in the lightbox 30 | expect(lightboxHeader).toBeInTheDocument(); 31 | }); 32 | }); 33 | 34 | describe('renderFooter', () => { 35 | test('renders custom footer', () => { 36 | render( 37 |