master 0e41af991138 cached
49 files
99.8 KB
24.7k tokens
28 symbols
1 requests
Download .txt
Repository: tim-soft/react-spring-lightbox
Branch: master
Commit: 0e41af991138
Files: 49
Total size: 99.8 KB

Directory structure:
gitextract_1p6vrvbp/

├── .babelrc.js
├── .browserslistrc
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .prettierignore
├── .prettierrc.js
├── .travis.yml
├── .vscode/
│   └── settings.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── example/
│   ├── README.md
│   ├── components/
│   │   ├── GalleryLightbox/
│   │   │   ├── components/
│   │   │   │   ├── GridImage.jsx
│   │   │   │   ├── LightboxArrowButton.jsx
│   │   │   │   ├── LightboxButtonControl.jsx
│   │   │   │   └── LightboxHeader.jsx
│   │   │   └── index.jsx
│   │   └── InlineLightbox/
│   │       └── index.jsx
│   ├── next-env.d.ts
│   ├── next.config.js
│   ├── package.json
│   ├── pages/
│   │   ├── _app.jsx
│   │   ├── _document.jsx
│   │   └── index.tsx
│   └── tsconfig.json
├── jest-setup.ts
├── jest.config.js
├── package.json
├── rollup.config.mjs
├── src/
│   ├── __tests__/
│   │   ├── components/
│   │   │   └── SimpleLightbox.tsx
│   │   └── lightbox.test.tsx
│   ├── components/
│   │   ├── CreatePortal/
│   │   │   └── index.tsx
│   │   ├── ImageStage/
│   │   │   ├── components/
│   │   │   │   ├── Image/
│   │   │   │   │   └── index.tsx
│   │   │   │   ├── ImagePager/
│   │   │   │   │   └── index.tsx
│   │   │   │   └── SSRImagePager/
│   │   │   │       └── SSRImagePager.tsx
│   │   │   ├── index.tsx
│   │   │   └── utils/
│   │   │       ├── getTranslateOffsetsFromScale.ts
│   │   │       ├── imageIsOutOfBounds.ts
│   │   │       ├── index.ts
│   │   │       ├── useDoubleClick.tsx
│   │   │       └── useRefSize.tsx
│   │   ├── PageContainer/
│   │   │   └── index.tsx
│   │   └── index.tsx
│   ├── index.tsx
│   └── types/
│       └── ImagesList.ts
├── tsconfig.buildtypes.json
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .babelrc.js
================================================
module.exports = {
    plugins: [
        ['@babel/plugin-transform-class-properties'],
        ['@babel/plugin-transform-object-rest-spread'],
        ['@babel/plugin-transform-runtime', { regenerator: false }],
    ],
    presets: ['@babel/env', '@babel/react', '@babel/preset-typescript'],
};


================================================
FILE: .browserslistrc
================================================
> 0.5%, last 2 versions, Firefox ESR, not dead


================================================
FILE: .editorconfig
================================================
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true


================================================
FILE: .eslintignore
================================================
# See https://help.github.com/ignore-files/ for more about ignoring files.

# dependencies
/node_modules
example/node_modules/**

# production
/build
/dist
/coverage

# misc
.DS_Store
.env
npm-debug.log*
yarn-debug.log*
yarn-error.log*
yarn.lock*
package.json*
CHANGELOG.md*
README.md*

example/.DS_Store
example/.env
example/npm-debug.log*
example/yarn-debug.log*
example/yarn-error.log*
example/yarn.lock*


================================================
FILE: .eslintrc.js
================================================
/* eslint-disable sort-keys */
/**
 * Configure ESLint
 *
 * https://eslint.org/docs/user-guide/configuring
 */
module.exports = {
    env: {
        browser: true,
        es6: true,
        jest: true,
    },
    extends: [
        'plugin:react/recommended',
        'plugin:import/warnings',
        'plugin:@typescript-eslint/recommended',
        'plugin:prettier/recommended',
    ],
    globals: {
        document: true,
        window: true,
    },
    parser: '@typescript-eslint/parser',
    parserOptions: {
        sourceType: 'module',
    },
    plugins: [
        'prettier',
        'jsx-a11y',
        'react',
        'react-hooks',
        'import',
        'sort-destructure-keys',
        '@typescript-eslint',
    ],
    root: true,
    rules: {
        'prettier/prettier': ['error', { endOfLine: 'auto' }],
        // Enforce React Hooks rules
        // https://www.npmjs.com/package/eslint-plugin-react-hooks
        'react-hooks/rules-of-hooks': 'error',
        'react-hooks/exhaustive-deps': 'warn',

        'sort-destructure-keys/sort-destructure-keys': [
            'error',
            { caseSensitive: false },
        ],
        'sort-keys': ['error', 'asc', { caseSensitive: false, natural: false }],
        'sort-vars': [
            'error',
            {
                ignoreCase: true,
            },
        ],
        'react/jsx-sort-props': ['error', { ignoreCase: true }],
        '@typescript-eslint/ban-ts-comment': 'off',
        '@typescript-eslint/no-explicit-any': 'off',
        '@typescript-eslint/explicit-module-boundary-types': 'off',
        '@typescript-eslint/member-ordering': [
            'error',
            {
                default: {
                    order: 'alphabetically',
                },
                classes: {
                    order: 'as-written',
                },
            },
        ],
    },
    settings: {
        'import/resolver': {
            node: true,
            'eslint-import-resolver-typescript': true,
        },
        react: {
            version: 'detect',
        },
    },
};


================================================
FILE: .gitignore
================================================

# See https://help.github.com/ignore-files/ for more about ignoring files.

# dependencies
node_modules

# builds
coverage
build
dist
.rpt2_cache
.next

# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local

npm-debug.log*
yarn-debug.log*
yarn-error.log*


================================================
FILE: .prettierignore
================================================
node_modules/**
.next/**
dist/**
coverage/**
example/node_modules/**
example/.next/**
yarn.lock
yarn-error.log
.editorconfig
.eslintignore
.gitignore
.prettierignore
.browserslistrc


================================================
FILE: .prettierrc.js
================================================
/**
 * Configure Prettier
 *
 * https://prettier.io/docs/en/configuration.html#basic-configuration
 */
module.exports = {
    endOfLine: 'auto',
    semi: true,
    singleQuote: true,
    tabWidth: 4,
};


================================================
FILE: .travis.yml
================================================
language: node_js
node_js:
    - 'lts/*'
cache:
    yarn: true
    directories:
        - node_modules
install: yarn install
jobs:
    include:
        - stage: lint
          script: yarn lint
        - stage: test
          script: yarn test
        - stage: build
          script: yarn build
branches:
    only: master
notifications:
    email: false


================================================
FILE: .vscode/settings.json
================================================
{
    "editor.formatOnSave": true,
    "javascript.validate.enable": false,
    "editor.tabSize": 4,
    "editor.detectIndentation": false,
    "eslint.enable": true
}


================================================
FILE: CHANGELOG.md
================================================
# Changelog

All notable changes to this project will be documented in this file.

## Upcoming

-   Add `swipe up` to close lightbox

## [1.8.0] - 2023-10-06

-   Add inline mode SSR support
-   fix next.js + styled-components + SWC client/server classname mismatch error
-   Upgrade several dependencies
    -   @react-spring/web ^9.7
    -   rollup 3
    -   typescript 5

### Potentially Breaking

    This release removes the esm build as it doesn't work well with next.js + SWC compiler

## [1.7.1] - 2022-09-12

### Fixed

-   Allow vertical scrolling with an InlineLightbox on mobile (even while finger is over the image)

## [1.7.0] - 2022-07-28

-   A few small bug fixes in situations where the `items` array may change size
-   Upgrade several dependencies
    -   "@react-spring/web": "9.5.2"
    -   "rollup": "^2.77.2",

### Added

-   `Lightbox` can now be used as a slider component embedded in a page like slick-slider by using the `inline` prop

## [1.6.0] - 2021-06-06

-   Upgrade to `@react-spring/web@9.2.1` stable from `@tim-soft/react-spring-web@9.0.0-beta.36`

## [1.5.0] - 2021-02-17

-   Rewrite project with typescript 4
-   Upgrade `react-use-gesture@7.0.15` to `react-use-gesture@9.0.4`
    -   This upgrade should fix some miscellaneous bugs such as `unable to spread non iterable instance` and more consistent trackpad support

### Added

-   The `images` prop now accepts a list of objects whose properties can be _almost_ any valid React `<img />` prop including `srcset` and `loading` (lazy loading)

If you use typescript, the exact type can be imported/used like this

```typescript
import Lightbox, { ImagesListType } from 'react-spring-lightbox';

const images: ImagesListType = [
    {
        alt: 'Windows 10 Dark Mode Setting',
        'aria-details': 'Some details',
        'aria-disabled': 'false',
        loading: 'lazy',
        src: 'https://timellenberger.com/static/blog-content/dark-mode/win10-dark-mode.jpg',
        srcSet: '/wp-content/uploads/flamingo4x.jpg 4x, /wp-content/uploads/flamingo3x.jpg 3x, /wp-content/uploads/flamingo2x.jpg 2x, /wp-content/uploads/flamingo1x.jpg 1x',
    },
    {
        alt: 'macOS Mojave Dark Mode Setting',
        'aria-details': 'Some details',
        'aria-disabled': 'false',
        loading: 'lazy',
        src: 'https://timellenberger.com/static/blog-content/dark-mode/macos-dark-mode.png',
        srcSet: '/wp-content/uploads/flamingo4x.jpg 4x, /wp-content/uploads/flamingo3x.jpg 3x, /wp-content/uploads/flamingo2x.jpg 2x, /wp-content/uploads/flamingo1x.jpg 1x',
    },
];

const SimpleLightbox = () => <Lightbox images={images} {...otherProps} />;
```

The exact type is:

```typescript
export type ImagesListItem = Omit<
    React.HTMLProps<HTMLImageElement>,
    'draggable' | 'onClick' | 'onDragStart' | 'ref'
> & { alt: string; loading?: 'auto' | 'eager' | 'lazy'; src: string };
```

Which translates to any React `<img />` prop minus `draggable`, `onClick`, `onDragStart` and `ref` as they are used internally. `alt` and `src` are required and explicitly support `loading` as it is an experimental chrome feature not included in `React.HTMLProps<HTMLImageElement>`.

## [1.4.11] - 2020-06-10

### Fixed

-   Use aliased version of react-spring dependencies, fixes "Cannot read property 'ref' of null" error

## [1.4.10] - 2020-05-03

### Added

-   Optimize output bundles with Terser
-   Apply `babel-plugin-styled-components` babel plugin to optimize styled-components styles

## [1.4.9] - 2020-05-02

### Fixed

-   Fix partially off-screen image stage in ie11

### Added

-   Upgrade to `rollup@2.7.6`, `react-use-gesture@7.0.15` and `@babel/****@7.9.6`

## [1.4.8] - 2020-04-08

### Fixed

-   Dropped `lodash.clamp` dependency
-   Call onPrev/onNext callbacks on all paging events, even at the beginning or end of image array to allow for infinite paging

## [1.4.7] - 2020-04-05

### Added

-   Lower distance and velocity gesture threshold for a paging between images
-   Allow click to zoom while a paging animation completes
-   Upgrade to `rollup@2.3.3` and `react-use-gesture@7.0.10`
-   Add `sideEffects: false` to `package.json`

## [1.4.6] - 2020-04-04

### Fixed

-   Handle edge case bugs with `singleClickToZoom` option
-   Fix undefined errors in panning drag handler on initial drags

## [1.4.5] - 2020-03-27

### Added

-   Add optional `singleClickToZoom` prop which allows single click/tap zooming on images

## [1.4.4] - 2020-03-24

### Fixed

-   Add orientationchange event listener for ios devices

## [1.4.3] - 2020-03-23

### Fixed

-   Drop lodash.merge
-   Fix image heights not adjusting on window resize

## [1.4.2] - 2020-03-15

### Fixed

-   Remove need for react-use-measure and @juggle/resize-observer

## [1.4.1] - 2020-03-13

### Fixed

-   Fix image stage height on Safari
-   Upgrade to react-use-gesture@7.0.5
-   Upgrade to rollup@2.0.6

## [1.4.0] - 2020-03-7

### BREAKING CHANGE

-   Replaced inline styles with styled-components. This library now has a peer dependency on `styled-components@5`

### Fixed

-   Gigantic initial image size in Firefox and MS Edge
-   Click background to close functionality

### Added

-   Vendor prefixed styles
-   A resize observer polyfill is now included to support MS Edge

## [1.2.1] - 2020-03-5

### Added

-   Added `renderImageOverlay` prop, renders a React component within the image stage, useful for creating UI overlays on top of the current image

## [1.2.0] - 2020-02-14

-   Upgrade react-use-gesture v6 -> v7
-   Upgrade all deps

## [1.1.7] - 2019-09-23

### Fixed

-   Improved panning performance
-   Tweaked mousewheel swiping threshold
-   Upgrade to react-use-gesture v6

## [1.1.4] - 2019-08-27

### Added

-   Implement mousewheel paging of images

## [1.1.3] - 2019-08-19

### Fixed

-   Prevent vertical dragging from paging images
-   Switch to @react-spring/web package

## [1.1.2] - 2019-08-17

### Fixed

-   Properly dispose wheel event listener

## [1.1.1] - 2019-08-14

### Fixed

-   Adjusted "pan out of bounds" threshold

## [1.1.0] - 2019-08-14

### Added

-   Implement proper <kbd>Ctrl</kbd> + `Mousewheel` and `Trackpad Pinch` zooming

## [1.0.1] - 2019-08-7

Add testing suite and travis-ci config

## [1.0.0] - 2019-08-5

Upgrade deps and release as stable

## [0.0.3] - 2019-08-1

### Changed

-   Renamed onClickNext => onNext
-   Renamed onClickPrev => onPrev

## [0.0.2] - 2019-07-31

Initial Release


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2019-present Tim Ellenberger

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# react-spring-lightbox

[![npm](https://img.shields.io/npm/v/react-spring-lightbox.svg?color=brightgreen&style=popout-square)](https://www.npmjs.com/package/react-spring-lightbox)
[![NPM](https://img.shields.io/npm/l/react-spring-lightbox.svg?color=brightgreen&style=popout-square)](https://github.com/tim-soft/react-spring-lightbox/blob/master/LICENSE)
![npm bundle size](https://img.shields.io/bundlephobia/minzip/react-spring-lightbox.svg?style=popout-square)
![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=popout-square)
[![Travis (.org)](https://img.shields.io/travis/tim-soft/react-spring-lightbox?style=flat-square)](https://travis-ci.org/tim-soft/react-spring-lightbox)

React-spring-lightbox is a flexible image gallery lightbox with native-feeling touch gestures and buttery smooth animations.

<p align="middle">
  <a href="https://71hts.csb.app/">
    <img src="https://thumbs.gfycat.com/CrispGeneralEquestrian-size_restricted.gif" />
  </a>
  <br />
  <a href="https://timellenberger.com/libraries/react-spring-lightbox">Docs</a>
  &nbsp;&nbsp;&nbsp;
  <a href="https://codesandbox.io/s/react-spring-lightbox-mosaic-71hts?fontsize=14&module=%2Fsrc%2FImageGallery%2Findex.js">Codesandbox</a>
</p>

## ✨ Features

-   :point_up: &nbsp;&nbsp;&nbsp;`Mousewheel`, swipe or click+drag to page photos
-   :keyboard: &nbsp;Keyboard controls <kbd>&leftarrow;</kbd> <kbd>&rightarrow;</kbd> <kbd>Esc</kbd>
-   :mouse2: &nbsp;<kbd>Ctrl</kbd> + `Mousewheel` or `Trackpad Pinch` to zoom
-   :mag_right: &nbsp;Double/Single-tap or double/single-click to zoom in/out
-   :ok_hand: &nbsp;&nbsp;&nbsp;Pinch to zoom
-   :point_left: &nbsp;Panning on zoomed-in images
-   :checkered_flag: &nbsp;Highly performant spring based animations via [react-spring](https://github.com/react-spring/react-spring)
-   No external CSS
-   Implement your own UI
-   Supports IE 11, Edge, Safari, Chrome, Firefox and Opera
-   Full typescript support
-   Supports any `<img />` attribute including `loading` (lazy loading), `srcset` and `aria-*`

## Install

```bash
yarn add react-spring-lightbox
```

## Usage

The `images` prop now accepts a list of objects whose properties can be _almost_ any valid React `<img />` prop including `srcset`, `loading` (lazy loading) and `aria-*` attributes.

If you use typescript, the exact type can be imported from `import { ImagesListType } from 'react-spring-lightbox';`

```typescript
import React, { useState } from 'react';
import Lightbox, { ImagesListType } from 'react-spring-lightbox';

const images: ImagesListType = [
    {
        src: 'https://timellenberger.com/static/blog-content/dark-mode/win10-dark-mode.jpg',
        loading: 'lazy',
        alt: 'Windows 10 Dark Mode Setting',
    },
    {
        src: 'https://timellenberger.com/static/blog-content/dark-mode/macos-dark-mode.png',
        loading: 'lazy',
        alt: 'macOS Mojave Dark Mode Setting',
    },
    {
        src: 'https://timellenberger.com/static/blog-content/dark-mode/android-9-dark-mode.jpg',
        loading: 'lazy',
        alt: 'Android 9.0 Dark Mode Setting',
    },
];

const CoolLightbox = () => {
    const [currentImageIndex, setCurrentIndex] = useState(0);

    const gotoPrevious = () =>
        currentImageIndex > 0 && setCurrentIndex(currentImageIndex - 1);

    const gotoNext = () =>
        currentImageIndex + 1 < images.length &&
        setCurrentIndex(currentImageIndex + 1);

    return (
        <Lightbox
            isOpen={true}
            onPrev={gotoPrevious}
            onNext={gotoNext}
            images={images}
            currentIndex={currentImageIndex}
            /* Add your own UI */
            // renderHeader={() => (<CustomHeader />)}
            // renderFooter={() => (<CustomFooter />)}
            // renderPrevButton={() => (<CustomLeftArrowButton />)}
            // renderNextButton={() => (<CustomRightArrowButton />)}
            // renderImageOverlay={() => (<ImageOverlayComponent >)}

            /* Add styling */
            // className="cool-class"
            // style={{ background: "grey" }}

            /* Handle closing */
            // onClose={handleClose}

            /* Use single or double click to zoom */
            // singleClickToZoom

            /* react-spring config for open/close animation */
            // pageTransitionConfig={{
            //   from: { transform: "scale(0.75)", opacity: 0 },
            //   enter: { transform: "scale(1)", opacity: 1 },
            //   leave: { transform: "scale(0.75)", opacity: 0 },
            //   config: { mass: 1, tension: 320, friction: 32 }
            // }}
        />
    );
};

export default CoolLightbox;
```

## Props

| Prop                 | Description                                                                                                        |
| -------------------- | ------------------------------------------------------------------------------------------------------------------ |
| isOpen               | Flag that dictates if the lightbox is open or closed                                                               |
| onClose              | Function that closes the Lightbox                                                                                  |
| onPrev               | Function that changes currentIndex to previous image in images                                                     |
| onNext               | Function that changes currentIndex to next image in images                                                         |
| currentIndex         | Index of image in images array that is currently shown                                                             |
| renderHeader         | A React component that renders above the image pager                                                               |
| renderFooter         | A React component that renders below the image pager                                                               |
| renderPrevButton     | A React component that is used for previous button in image pager                                                  |
| renderNextButton     | A React component that is used for next button in image pager                                                      |
| renderImageOverlay   | A React component that renders within the image stage, useful for creating UI overlays on top of the current image |
| singleClickToZoom    | Overrides the default behavior of double clicking causing an image zoom to a single click                          |
| images               | Array of image objects to be shown in Lightbox                                                                     |
| className            | Classes are applied to the root lightbox component                                                                 |
| style                | Inline styles are applied to the root lightbox component                                                           |
| pageTransitionConfig | React-Spring useTransition config for page open/close animation                                                    |

## Local Development

Clone the repo

```bash
git clone https://github.com/tim-soft/react-spring-lightbox.git react-spring-lightbox
cd react-spring-lightbox
```

Setup symlinks

```bash
yarn link
cd example
yarn link react-spring-lightbox
```

Run the library in development mode

```bash
yarn start
```

Run the example app in development mode

```bash
cd example
yarn dev
```

Changes to the library code should hot reload in the demo app

## License

MIT © [Tim Ellenberger](https://github.com/tim-soft)


================================================
FILE: example/README.md
================================================
# react-spring-lightbox demo app

This directory contains a Next.js app useful for testing and developing `react-spring-lightbox`

Install dependencies

```bash
yarn install
```

Run locally with hot module reloading

```bash
yarn dev
```


================================================
FILE: example/components/GalleryLightbox/components/GridImage.jsx
================================================
import * as React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';

/**
 * A single image element in a masonry style image grid
 */
const GridImage = ({ index, key, left, onClick, photo, top }) => {
    const { alt, caption, height, src, width } = photo;
    return (
        <ImageContainer
            index={index}
            key={`${key}-${index}`}
            onClick={(e) => onClick(e, { index })}
            style={{ height, left, top, width }}
        >
            <OverlayContainer>
                <Image alt={alt} caption={caption} src={src} />
                <Caption>
                    <h4>{caption}</h4>
                </Caption>
            </OverlayContainer>
        </ImageContainer>
    );
};

GridImage.propTypes = {
    containerHeight: PropTypes.number.isRequired,
    index: PropTypes.number.isRequired,
    key: PropTypes.string.isRequired,
    left: PropTypes.number.isRequired,
    onClick: PropTypes.func.isRequired,
    photo: PropTypes.shape({
        alt: PropTypes.string.isRequired,
        caption: PropTypes.string.isRequired,
        height: PropTypes.number.isRequired,
        src: PropTypes.string.isRequired,
        width: PropTypes.number.isRequired,
    }).isRequired,
    top: PropTypes.number.isRequired,
};

export default GridImage;

const Caption = styled.div`
    position: absolute;
    bottom: 0;
    width: 100%;
    background-color: ${({ theme }) => theme.accentColor};
    color: ${({ theme }) => theme.pageContentLinkHoverColor};
    h4 {
        text-align: center;
        margin: 1em 0;
    }
`;

const OverlayContainer = styled.div`
    position: relative;
    height: 100%;
    overflow: hidden;
`;

const ImageContainer = styled.div`
    display: block;
    position: absolute;
    cursor: pointer;
    border-width: 2px;
    border-color: transparent;
    border-style: solid;
    :hover {
        border-color: ${({ theme }) => theme.pageContentLinkHoverColor};
    }
`;

const Image = styled.img`
    width: inherit;
    height: inherit;
    position: absolute;
`;


================================================
FILE: example/components/GalleryLightbox/components/LightboxArrowButton.jsx
================================================
/* eslint-disable no-shadow */
import * as React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { IoIosArrowBack, IoIosArrowForward } from 'react-icons/io';
import { animated, useTransition } from '@react-spring/web';
import ButtonControl from './LightboxButtonControl';

const ArrowButton = ({ className, disabled, onClick, position }) => {
    const transitions = useTransition(!disabled, {
        enter: { opacity: 1 },
        from: { opacity: 0 },
        leave: { opacity: 0 },
    });

    return transitions(
        (props, item) =>
            item && (
                <StyledAnimatedDiv className={className} style={props}>
                    <Button onClick={onClick} position={position} type="button">
                        {position === 'left' && <IoIosArrowBack />}
                        {position === 'right' && <IoIosArrowForward />}
                    </Button>
                </StyledAnimatedDiv>
            ),
    );
};

ArrowButton.propTypes = {
    disabled: PropTypes.bool,
    onClick: PropTypes.func.isRequired,
    position: PropTypes.oneOf(['left', 'right']).isRequired,
};

ArrowButton.defaultProps = {
    disabled: false,
};

export default ArrowButton;

const StyledAnimatedDiv = styled(animated.div)`
    z-index: 999;
`;

const Button = styled(ButtonControl)`
    position: absolute;
    top: 0;
    bottom: 0;
    left: ${({ position }) => (position === 'left' ? 0 : 'unset')};
    right: ${({ position }) => (position === 'right' ? 0 : 'unset')};
`;


================================================
FILE: example/components/GalleryLightbox/components/LightboxButtonControl.jsx
================================================
import styled from 'styled-components';

export default styled.button`
    z-index: 10;
    background: none;
    border-style: none;
    font-size: 50px;
    cursor: pointer;
    padding: 0;
    margin: 0;
    color: ${({ theme }) => theme.pageContentFontColor};
    transition: color 0.2s linear;
    :hover {
        color: ${({ theme }) => theme.pageContentLinkHoverColor};
    }
    :focus {
        outline: none;
        color: ${({ theme }) => theme.pageContentLinkHoverColor};
    }
`;


================================================
FILE: example/components/GalleryLightbox/components/LightboxHeader.jsx
================================================
import * as React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import { IoIosClose } from 'react-icons/io';
import Color from 'color';
import ButtonControl from './LightboxButtonControl';

const LightboxHeader = ({ currentIndex, galleryTitle, images, onClose }) => (
    <TopHeaderBar>
        <LeftSideDescriptionContainer>
            <GalleryHeading>{galleryTitle}</GalleryHeading>
            <GallerySubheading>
                {images[currentIndex].caption}
            </GallerySubheading>
        </LeftSideDescriptionContainer>

        <RightSideContainer>
            <PageIndicator>
                {currentIndex + 1} / {images.length}
            </PageIndicator>
            <CloseButton onClick={onClose} type="button">
                <IoIosClose size={60} />
            </CloseButton>
        </RightSideContainer>
    </TopHeaderBar>
);

LightboxHeader.propTypes = {
    currentIndex: PropTypes.number.isRequired,
    galleryTitle: PropTypes.string.isRequired,
    images: PropTypes.arrayOf(
        PropTypes.shape({
            alt: PropTypes.string.isRequired,
            caption: PropTypes.string.isRequired,
            height: PropTypes.number,
            src: PropTypes.string.isRequired,
            width: PropTypes.number,
        }),
    ).isRequired,
    onClose: PropTypes.func.isRequired,
};

export default LightboxHeader;

const GalleryHeading = styled.h2`
    margin: 0 0 5px 0;
    font-weight: normal;
`;

const GallerySubheading = styled.h4`
    margin: 0;
    font-weight: normal;
    color: ${({ theme }) => theme.pageContentLinkHoverColor};
`;

const PageIndicator = styled.span`
    white-space: nowrap;
    min-width: 60px;
    text-align: center;
`;

const RightSideContainer = styled.div`
    width: 117px;
    display: flex;
    justify-content: space-between;
    align-items: center;
`;

const CloseButton = styled(ButtonControl)`
    height: 100%;
    display: flex;
    border-left-style: solid;
    border-left-width: 3px;
    border-left-color: ${({ theme }) => theme.headerNavFontColor};
    color: inherit;
`;

const LeftSideDescriptionContainer = styled.div`
    display: flex;
    flex-direction: column;
    justify-content: center;
    border-left-width: 3px;
    border-left-color: ${({ theme }) => theme.pageContentLinkHoverColor};
    border-left-style: solid;
    padding: 8px 0 8px 10px;
`;

const TopHeaderBar = styled.header`
    z-index: 10;
    cursor: auto;
    display: flex;
    justify-content: space-between;
    padding: 10px 2px 10px 20px;
    color: ${({ theme }) => theme.headerNavFontColor};
    background-color: ${({ theme }) =>
        Color(theme.pageBackgroundColor).alpha(0.5).hsl().string()};
    > * {
        height: inherit;
    }
`;


================================================
FILE: example/components/GalleryLightbox/index.jsx
================================================
import { FiHeart, FiPrinter, FiShare } from 'react-icons/fi';
import React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import Color from 'color';
import Gallery from 'react-photo-gallery';
import Lightbox from 'react-spring-lightbox';
import GridImage from './components/GridImage';
import LightboxHeader from './components/LightboxHeader';
import LightboxArrowButton from './components/LightboxArrowButton';

class BlogImageGallery extends React.Component {
    static propTypes = {
        galleryTitle: PropTypes.string.isRequired,
        imageMasonryDirection: PropTypes.oneOf(['column', 'row']),
        images: PropTypes.arrayOf(
            PropTypes.shape({
                alt: PropTypes.string.isRequired,
                caption: PropTypes.string.isRequired,
                height: PropTypes.number,
                src: PropTypes.string.isRequired,
                width: PropTypes.number,
            }),
        ).isRequired,
    };

    static defaultProps = {
        imageMasonryDirection: 'column',
    };

    constructor() {
        super();

        this.state = {
            clientSide: false,
            currentImageIndex: 0,
            lightboxIsOpen: false,
        };
    }

    componentDidMount() {
        this.setState({ clientSide: true });
    }

    openLightbox = (e, { index }) => {
        this.setState({
            currentImageIndex: index,
            lightboxIsOpen: true,
        });
    };

    closeLightbox = () => {
        this.setState({
            lightboxIsOpen: false,
        });
    };

    gotoPrevious = () => {
        const { currentImageIndex } = this.state;

        // If the current image isn't the first in the list, go to the previous
        if (currentImageIndex > 0) {
            this.setState({
                currentImageIndex: currentImageIndex - 1,
            });
        }
    };

    gotoNext = () => {
        const { images } = this.props;
        const { currentImageIndex } = this.state;

        // If the current image isn't the list in the list, go to the next
        if (currentImageIndex + 1 < images.length) {
            this.setState({
                currentImageIndex: currentImageIndex + 1,
            });
        }
    };

    /**
     * Sets breakpoints for column width based on containerWidth
     *
     * @int containerWidth The current width of the image grid
     */
    columnConfig = (containerWidth) => {
        let columns = 1;
        if (containerWidth >= 500) columns = 2;
        if (containerWidth >= 900) columns = 3;
        if (containerWidth >= 1500) columns = 4;

        return columns;
    };

    render() {
        const { clientSide, currentImageIndex, lightboxIsOpen } = this.state;
        const { galleryTitle, imageMasonryDirection, images } = this.props;

        // remove the height and width props for the lightbox images array
        const listboxImages = [...images].map((image) => {
            const newImage = { ...image };
            delete newImage.height;
            delete newImage.width;

            return newImage;
        });

        return (
            <GalleryContainer>
                {clientSide && (
                    <Gallery
                        columns={this.columnConfig}
                        direction={imageMasonryDirection}
                        margin={6}
                        onClick={this.openLightbox}
                        photos={images}
                        renderImage={GridImage}
                    />
                )}
                <StyledLightbox
                    currentIndex={currentImageIndex}
                    galleryTitle={galleryTitle}
                    images={listboxImages}
                    isOpen={lightboxIsOpen}
                    onClose={this.closeLightbox}
                    onNext={this.gotoNext}
                    onPrev={this.gotoPrevious}
                    renderHeader={() => (
                        <LightboxHeader
                            currentIndex={currentImageIndex}
                            galleryTitle={galleryTitle}
                            images={images}
                            onClose={this.closeLightbox}
                        />
                    )}
                    renderImageOverlay={() => (
                        <ImageOverlay>
                            <p>Create your own UI</p>
                            <FiPrinter size="3em" />
                            <FiShare size="3em" />
                            <FiHeart size="3em" />
                        </ImageOverlay>
                    )}
                    renderNextButton={({ canNext }) => (
                        <LightboxArrowButton
                            disabled={!canNext}
                            onClick={this.gotoNext}
                            position="right"
                        />
                    )}
                    renderPrevButton={({ canPrev }) => (
                        <LightboxArrowButton
                            disabled={!canPrev}
                            onClick={this.gotoPrevious}
                            position="left"
                        />
                    )}
                    singleClickToZoom
                />
            </GalleryContainer>
        );
    }
}

export default BlogImageGallery;

const GalleryContainer = styled.div`
    overflow-y: auto;
    max-height: calc(100% - 4em);
    padding: 2em;
`;

const StyledLightbox = styled(Lightbox)`
    background: ${({ theme }) =>
        Color(theme.accentColor).alpha(0.95).hsl().string()};
    * ::selection {
        background: ${({ theme }) => theme.pageContentSelectionColor};
    }
    * ::-moz-selection {
        background: ${({ theme }) =>
            new Color(theme.pageContentSelectionColor).darken(0.57).hex()};
    }
`;

const ImageOverlay = styled.div`
    position: absolute;
    top: 0%;
    right: 0%;
    border: ${({ theme }) => theme.pageContentSelectionColor} 1px solid;
    background: rgba(39, 39, 39, 0.5);
    p {
        color: ${({ theme }) => theme.pageContentSelectionColor};
        text-align: center;
        font-weight: bold;
        font-size: 1.2em;
        margin: 0.5em 0;
    }
    svg {
        border: white 1px solid;
        fill: ${({ theme }) => theme.pageContentSelectionColor};
        margin: 10px;
        padding: 5px;
        :hover {
            border: ${({ theme }) => theme.pageContentSelectionColor} 1px solid;
            fill: ${({ theme }) =>
                new Color(theme.pageContentSelectionColor).darken(0.57).hex()};
            cursor: pointer;
        }
    }
`;


================================================
FILE: example/components/InlineLightbox/index.jsx
================================================
import * as React from 'react';
import PropTypes from 'prop-types';
import styled from 'styled-components';
import Lightbox from 'react-spring-lightbox';
import LightboxArrowButton from '../GalleryLightbox/components/LightboxArrowButton';

const InlineLightbox = ({ images }) => {
    const [currentImageIndex, setCurrentImageIndex] = React.useState(0);
    const inlineCarouselElement = React.useRef();

    React.useEffect(() => {
        inlineCarouselElement?.current?.addEventListener('wheel', preventWheel);

        setCurrentImageIndex(0);
    }, [inlineCarouselElement, images]);

    const preventWheel = (e) => {
        e.preventDefault();
        e.stopPropagation();
        return false;
    };

    const canPrev = currentImageIndex > 0;
    const canNext = currentImageIndex + 1 < images.length;

    const gotoNext = () => {
        canNext ? setCurrentImageIndex(currentImageIndex + 1) : () => null;
    };

    const gotoPrevious = () => {
        canPrev ? setCurrentImageIndex(currentImageIndex - 1) : null;
    };

    return (
        <Container ref={inlineCarouselElement}>
            <Lightbox
                currentIndex={currentImageIndex}
                images={images}
                inline
                isOpen
                onNext={gotoNext}
                onPrev={gotoPrevious}
                renderNextButton={({ canNext }) => (
                    <StyledLightboxArrowButton
                        disabled={!canNext}
                        onClick={gotoNext}
                        position="right"
                    />
                )}
                renderPrevButton={({ canPrev }) => (
                    <StyledLightboxArrowButton
                        disabled={!canPrev}
                        onClick={gotoPrevious}
                        position="left"
                    />
                )}
                singleClickToZoom
            />
        </Container>
    );
};

export default InlineLightbox;

InlineLightbox.propTypes = {
    images: PropTypes.arrayOf(
        PropTypes.shape({
            alt: PropTypes.string.isRequired,
            caption: PropTypes.string.isRequired,
            height: PropTypes.number,
            src: PropTypes.string.isRequired,
            width: PropTypes.number,
        }),
    ).isRequired,
};

const Container = styled.div`
    display: inline-flex;
    flex-direction: column;
    width: 100%;
    height: 384px;
    overflow: hidden;
`;

const StyledLightboxArrowButton = styled(LightboxArrowButton)`
    z-index: 10;
    button {
        font-size: 25px;
    }
`;


================================================
FILE: example/next-env.d.ts
================================================
/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.


================================================
FILE: example/next.config.js
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
    compiler: {
        styledComponents: true,
    },
    transpilePackages: ['styled-components', 'react-spring-lightbox'],
};

module.exports = nextConfig;


================================================
FILE: example/package.json
================================================
{
    "name": "react-spring-lightbox-example",
    "homepage": "https://tim-soft.github.io/react-spring-lightbox",
    "version": "0.0.0",
    "license": "MIT",
    "private": true,
    "dependencies": {
        "@react-spring/web": "link:../node_modules/@react-spring/web",
        "color": "^4.2.3",
        "lodash.clamp": "^4.0.3",
        "lodash.merge": "^4.6.2",
        "next": "13.5.4",
        "prop-types": "^15.6.2",
        "react": "link:../node_modules/react",
        "react-dom": "link:../node_modules/react-dom",
        "react-icons": "^4.2.0",
        "react-is": "link:../node_modules/react-is",
        "react-photo-gallery": "^8.0.0",
        "react-spring-lightbox": "link:..",
        "styled-components": "link:../node_modules/styled-components",
        "styled-normalize": "^8.0.7"
    },
    "scripts": {
        "dev": "next",
        "start": "next start",
        "build": "next build"
    },
    "browserslist": [
        ">0.2%",
        "not dead",
        "not ie <= 11",
        "not op_mini all"
    ]
}


================================================
FILE: example/pages/_app.jsx
================================================
import App from 'next/app';
import React from 'react';
import { createGlobalStyle, ThemeProvider } from 'styled-components';
import styledNormalize from 'styled-normalize';

export default class MyApp extends App {
    render() {
        const { Component, pageProps } = this.props;
        return (
            <>
                {/* Adds some basic body styles */}
                <DefaultStyles />

                <ThemeProvider
                    theme={{
                        accentColor: '#1f1f1f',
                        headerNavFontColor: '#e2e5ec',
                        pageBackgroundColor: '#101010',
                        pageContentFontColor: '#e2e5ec',
                        pageContentLinkHoverColor: 'aquamarine',
                        pageContentSelectionColor: 'aquamarine',
                    }}
                >
                    <Component {...pageProps} />
                </ThemeProvider>
            </>
        );
    }
}

/**
 * Adds global styles and normalize.css to the entire app
 *
 * http://nicolasgallagher.com/about-normalize-css/
 * https://www.styled-components.com/docs/api#createglobalstyle
 */
const DefaultStyles = createGlobalStyle`
 ${styledNormalize}
 body {
   margin: 0;
   background: #1D1E1F;
   font-family: 'Montserrat', sans-serif;
   -ms-text-size-adjust: 100%;
   -webkit-text-size-adjust: 100%;
   -moz-osx-font-smoothing: grayscale;
   -webkit-font-smoothing: antialiased;
 }
`;


================================================
FILE: example/pages/_document.jsx
================================================
import * as React from 'react';
import Document from 'next/document';
import { ServerStyleSheet } from 'styled-components';

export default class MyDocument extends Document {
    static async getInitialProps(ctx) {
        const sheet = new ServerStyleSheet();
        const originalRenderPage = ctx.renderPage;

        try {
            ctx.renderPage = () =>
                originalRenderPage({
                    enhanceApp: (App) => (props) =>
                        sheet.collectStyles(<App {...props} />),
                });

            const initialProps = await Document.getInitialProps(ctx);
            return {
                ...initialProps,
                styles: (
                    <>
                        {initialProps.styles}
                        {sheet.getStyleElement()}
                    </>
                ),
            };
        } finally {
            sheet.seal();
        }
    }
}


================================================
FILE: example/pages/index.tsx
================================================
import * as React from 'react';
import styled from 'styled-components';
import GalleryLightbox from '../components/GalleryLightbox';
import InlineLightbox from '../components/InlineLightbox';

const images = [
    {
        alt: 'Windows 10 Dark Mode Setting',
        caption: 'Windows 10 Dark Mode Setting',
        height: 2035,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/win10-dark-mode.jpg',
        width: 2848,
    },
    {
        alt: 'macOS Mojave Dark Mode Setting',
        caption: 'macOS Mojave Dark Mode Setting',
        height: 1218,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/macos-dark-mode.png',
        width: 1200,
    },
    {
        alt: 'Android 9.0 Dark Mode Setting',
        caption: 'Android 9.0 Dark Mode Setting',
        height: 600,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/android-9-dark-mode.jpg',
        width: 1280,
    },
    {
        alt: 'Windows 10 Dark Mode Setting#',
        caption: 'Windows 10 Dark Mode Setting#',
        height: 2035,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/win10-dark-mode.jpg',
        width: 2848,
    },
    {
        alt: 'Windows 10 Dark Mode Setting',
        caption: 'Windows 10 Dark Mode Setting',
        height: 2035,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/win10-dark-mode.jpg',
        width: 2848,
    },
    {
        alt: 'macOS Mojave Dark Mode Setting',
        caption: 'macOS Mojave Dark Mode Setting',
        height: 1218,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/macos-dark-mode.png',
        width: 1200,
    },
    {
        alt: 'Android 9.0 Dark Mode Setting',
        caption: 'Android 9.0 Dark Mode Setting',
        height: 600,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/android-9-dark-mode.jpg',
        width: 1280,
    },
    {
        alt: 'Windows 10 Dark Mode Setting#',
        caption: 'Windows 10 Dark Mode Setting#',
        height: 2035,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/win10-dark-mode.jpg',
        width: 2848,
    },
    {
        alt: 'Android 9.0 Dark Mode Setting',
        caption: 'Android 9.0 Dark Mode Setting',
        height: 600,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/android-9-dark-mode.jpg',
        width: 1280,
    },
    {
        alt: 'Windows 10 Dark Mode Setting#',
        caption: 'Windows 10 Dark Mode Setting#',
        height: 2035,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/win10-dark-mode.jpg',
        width: 2848,
    },
    {
        alt: 'macOS Mojave Dark Mode Setting',
        caption: 'macOS Mojave Dark Mode Setting',
        height: 1218,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/macos-dark-mode.png',
        width: 1200,
    },
    {
        alt: 'Android 9.0 Dark Mode Setting',
        caption: 'Android 9.0 Dark Mode Setting',
        height: 600,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/android-9-dark-mode.jpg',
        width: 1280,
    },
    {
        alt: 'Windows 10 Dark Mode Setting#',
        caption: 'Windows 10 Dark Mode Setting#',
        height: 2035,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/win10-dark-mode.jpg',
        width: 2848,
    },
    {
        alt: 'Android 9.0 Dark Mode Setting',
        caption: 'Android 9.0 Dark Mode Setting',
        height: 600,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/android-9-dark-mode.jpg',
        width: 1280,
    },
    {
        alt: 'Windows 10 Dark Mode Setting#',
        caption: 'Windows 10 Dark Mode Setting#',
        height: 2035,
        src: 'https://timellenberger.com/static/blog-content/dark-mode/win10-dark-mode.jpg',
        width: 2848,
    },
];

const getRandomImages = (numImages: number) => {
    const imageArray = [...new Array(numImages)];

    const getRandomInt = (min, max) => {
        min = Math.ceil(min);
        max = Math.floor(max);
        return Math.floor(Math.random() * (max - min) + min); //The maximum is exclusive and the minimum is inclusive
    };

    const altCaption = 'picsum photo';
    const randomizedArray = imageArray.map((imageObj) => {
        const imageURL = `https://picsum.photos/id/${getRandomInt(
            1,
            200,
        )}/1920/1280`;
        return {
            ...imageObj,
            alt: altCaption,
            caption: altCaption,
            src: imageURL,
        };
    });

    return randomizedArray;
};

const HomePage = () => {
    const [inlineImages, setInlineImages] = React.useState(getRandomImages(15));

    return (
        <Container>
            <GalleryLightboxExample>
                <StyledH2>Gallery Lightbox</StyledH2>
                <GalleryLightbox
                    galleryTitle="Dark Mode: OS Level Control In Your CSS"
                    imageMasonryDirection="column"
                    images={images}
                />
            </GalleryLightboxExample>
            <hr />
            <InlineLightboxExampleContainer>
                <StyledH2>Inline Lightbox</StyledH2>
                <InlineLightboxExample>
                    <OtherInlineContent>
                        🎉🎉Inline content🎉🎉
                    </OtherInlineContent>
                    <InlineLightbox images={images} />
                    <OtherInlineContent>
                        🎉🎉Inline content🎉🎉
                    </OtherInlineContent>
                </InlineLightboxExample>
                <Button
                    onClick={() =>
                        setInlineImages(getRandomImages(inlineImages.length))
                    }
                >
                    Generate new images
                </Button>
                <Button
                    onClick={() => {
                        if (inlineImages.length === 1) {
                            setInlineImages(getRandomImages(15));
                        } else {
                            setInlineImages(getRandomImages(1));
                        }
                    }}
                >
                    Switch Image Array
                </Button>
            </InlineLightboxExampleContainer>
        </Container>
    );
};

export default HomePage;

const Container = styled.div`
    height: 100%;
    width: 100%;
    user-select: none;
    background: #272727;
    color: #fff;
    padding: 50px 0;
`;

const Button = styled.button`
    border-radius: 10px;
    background: teal;
    color: #fff;
    padding: 16px 10px;
    margin: 20px auto;
    cursor: pointer;
    :hover {
        background: darkblue;
    }
    :active {
        background: darkseagreen;
    }
`;

const GalleryLightboxExample = styled.div`
    height: 100%;
    width: 100%;
`;

const InlineLightboxExampleContainer = styled.div`
    height: 100%;
    width: 100%;
    display: flex;
    flex-direction: column;
`;
const InlineLightboxExample = styled.div`
    width: 100%;
    display: flex;
    justify-content: center;
`;

const OtherInlineContent = styled.div`
    width: 400px;
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 10px;
    background: blueviolet;
`;

const StyledH2 = styled.h2`
    text-align: center;
`;


================================================
FILE: example/tsconfig.json
================================================
{
    "compilerOptions": {
        "allowJs": true,
        "esModuleInterop": true,
        "incremental": true,
        "isolatedModules": true,
        "jsx": "preserve",
        "lib": ["dom", "dom.iterable", "esnext"],
        "module": "esnext",
        "moduleResolution": "node",
        "noEmit": true,
        "resolveJsonModule": true,
        "skipLibCheck": true,
        "strict": false
    },
    "exclude": ["node_modules"],
    "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
}


================================================
FILE: jest-setup.ts
================================================
import '@testing-library/jest-dom';


================================================
FILE: jest.config.js
================================================
/**
 * Configure Jest as the test runner for @testing-library
 *
 * @see https://jestjs.io/docs/en/configuration
 */
module.exports = {
    collectCoverage: true,
    coveragePathIgnorePatterns: ['/node_modules/', '/__tests__/'],
    preset: 'ts-jest',
    setupFilesAfterEnv: ['babel-polyfill', '<rootDir>/jest-setup.ts'],
    testEnvironment: 'jsdom',
    testMatch: ['**/__tests__/**/*.(spec|test).[jt]s?(x)'],
    transform: {
        '^.+\\.(js|jsx)$': 'babel-jest',
        '^.+\\.(ts|tsx)?$': 'ts-jest',
    },
};


================================================
FILE: package.json
================================================
{
    "name": "react-spring-lightbox",
    "version": "1.8.0",
    "description": "A flexible image gallery lightbox with native-feeling touch gestures and buttery smooth animations, built with react-spring.",
    "author": "Tim Ellenberger <timellenberger@gmail.com>",
    "license": "MIT",
    "repository": "tim-soft/react-spring-lightbox",
    "sideEffects": false,
    "bugs": {
        "url": "https://github.com/tim-soft/react-spring-lightbox/issues"
    },
    "homepage": "https://timellenberger.com",
    "keywords": [
        "react",
        "spring",
        "lightbox",
        "modal",
        "gallery",
        "touch",
        "gestures",
        "images"
    ],
    "main": "dist/index.cjs.js",
    "types": "dist/index.d.ts",
    "engines": {
        "node": ">=16",
        "npm": ">=7"
    },
    "scripts": {
        "fix": "yarn fix:eslint && yarn fix:prettier",
        "fix:eslint": "eslint --fix \"**/*.*\"",
        "fix:prettier": "prettier --write \"**/*.*\"",
        "lint": "yarn lint:eslint && yarn lint:prettier && yarn lint:ts",
        "lint:eslint": "eslint \"**/*.*\"",
        "lint:prettier": "prettier --check \"**/*.*\"",
        "lint:ts": "npx tsc --noEmit -p .",
        "test": "jest",
        "test:watch": "jest --watch",
        "build": "rollup --config && yarn run build:types",
        "build:types": "tsc --project tsconfig.buildtypes.json --emitDeclarationOnly",
        "start": "rollup --config --watch",
        "prepare": "yarn run build"
    },
    "husky": {
        "hooks": {
            "pre-commit": "lint-staged"
        }
    },
    "lint-staged": {
        "*.{json,md}": [
            "prettier --write",
            "git add --force"
        ],
        "*.{js, jsx}": [
            "prettier --write",
            "eslint --no-ignore --fix",
            "git add --force"
        ]
    },
    "peerDependencies": {
        "react": ">=16.8",
        "react-dom": ">=16.8",
        "styled-components": ">=5.X"
    },
    "devDependencies": {
        "@babel/core": "^7.23.0",
        "@babel/plugin-transform-class-properties": "^7.18.6",
        "@babel/plugin-transform-object-rest-spread": "^7.20.7",
        "@babel/plugin-transform-runtime": "^7.22.15",
        "@babel/preset-env": "^7.22.20",
        "@babel/preset-react": "^7.22.15",
        "@babel/preset-typescript": "^7.23.0",
        "@rollup/plugin-babel": "^6.0.4",
        "@rollup/plugin-commonjs": "^25.0.5",
        "@rollup/plugin-node-resolve": "^15.2.2",
        "@rollup/plugin-terser": "^0.4.4",
        "@testing-library/jest-dom": "^6.1.3",
        "@testing-library/react": "^14.0.0",
        "@types/jest": "^29.5.5",
        "@types/react": "^18.2.25",
        "@types/react-dom": "^18.2.10",
        "@types/styled-components": "5.1.28",
        "@typescript-eslint/eslint-plugin": "^6.7.4",
        "@typescript-eslint/parser": "^6.7.4",
        "babel-eslint": "10.1.0",
        "babel-polyfill": "^6.26.0",
        "cross-env": "^7.0.3",
        "eslint": "^8.50.0",
        "eslint-config-prettier": "^9.0.0",
        "eslint-import-resolver-typescript": "^3.6.1",
        "eslint-plugin-import": "^2.28.1",
        "eslint-plugin-jsx-a11y": "^6.7.1",
        "eslint-plugin-prettier": "^5.0.0",
        "eslint-plugin-react": "^7.33.2",
        "eslint-plugin-react-hooks": "^4.6.0",
        "eslint-plugin-sort-destructure-keys": "^1.5.0",
        "husky": "^4.3.8",
        "jest": "^29.7.0",
        "jest-environment-jsdom": "^29.7.0",
        "lint-staged": "^10.5.4",
        "prettier": "^3.0.3",
        "react": "^18",
        "react-dom": "^18",
        "react-is": "^18",
        "rollup": "^3.29.4",
        "rollup-plugin-filesize": "^10.0.0",
        "rollup-plugin-node-externals": "^6.1.2",
        "styled-components": "^5.3.5",
        "ts-jest": "^29.1.1",
        "tslib": "^2.6.2",
        "typescript": "^5.2.2"
    },
    "files": [
        "dist"
    ],
    "dependencies": {
        "@babel/runtime": "^7.23.1",
        "@react-spring/web": "^9.7.3",
        "react-use-gesture": "9.1.3"
    }
}


================================================
FILE: rollup.config.mjs
================================================
import commonjs from '@rollup/plugin-commonjs';
import filesize from 'rollup-plugin-filesize';
import { nodeResolve } from '@rollup/plugin-node-resolve';
import { babel } from '@rollup/plugin-babel';
import terser from '@rollup/plugin-terser';
import { nodeExternals } from 'rollup-plugin-node-externals';

export default {
    input: './src/index.tsx',
    output: [
        {
            exports: 'default',
            file: 'dist/index.cjs.js',
            format: 'cjs',
            interop: 'auto',
            sourcemap: true,
        },
    ],
    plugins: [
        nodeExternals(),
        nodeResolve(),
        commonjs({
            extensions: ['.js', '.jsx', '.ts', '.tsx'],
            include: 'node_modules/**',
        }),
        babel({
            babelHelpers: 'runtime',
            exclude: 'node_modules/**',
            extensions: ['.js', '.jsx', '.ts', '.tsx'],
        }),
        terser(),
        filesize(),
    ],
};


================================================
FILE: src/__tests__/components/SimpleLightbox.tsx
================================================
import React, { useState } from 'react';
import Lightbox, { ImagesListType } from '../../index';

const images: ImagesListType = [
    {
        alt: 'Windows 10 Dark Mode Setting',
        src: 'https://timellenberger.com/static/blog-content/dark-mode/win10-dark-mode.jpg',
    },
    {
        alt: 'macOS Mojave Dark Mode Setting',
        src: 'https://timellenberger.com/static/blog-content/dark-mode/macos-dark-mode.png',
    },
    {
        alt: 'Android 9.0 Dark Mode Setting',
        src: 'https://timellenberger.com/static/blog-content/dark-mode/android-9-dark-mode.jpg',
    },
];

const SimpleLightbox = (
    props: Partial<React.ComponentProps<typeof Lightbox>>,
) => {
    const [currentImageIndex, setCurrentIndex] = useState(0);

    const gotoPrevious = () =>
        currentImageIndex > 0 && setCurrentIndex(currentImageIndex - 1);

    const gotoNext = () =>
        currentImageIndex + 1 < images.length &&
        setCurrentIndex(currentImageIndex + 1);

    return (
        <Lightbox
            currentIndex={currentImageIndex}
            images={images}
            isOpen
            onClose={() => null}
            onNext={gotoNext}
            onPrev={gotoPrevious}
            {...props}
        />
    );
};

export default SimpleLightbox;


================================================
FILE: src/__tests__/lightbox.test.tsx
================================================
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import Lightbox from './components/SimpleLightbox';

describe('Lightbox', () => {
    describe('CreatePortal', () => {
        test('creates portal on render', () => {
            render(<Lightbox />);
            const portalEl = document.body.querySelector('.lightbox-portal');
            expect(portalEl).toBeTruthy();
        });
    });

    describe('renderHeader', () => {
        test('renders custom header', () => {
            render(
                <Lightbox
                    renderHeader={() => <header data-testid="header" />}
                />,
            );

            const lightboxContainer = screen.getByTestId('lightbox-container');
            const lightboxHeader = screen.getByTestId('header');

            // Lightbox container should have a header and pager body
            expect(lightboxContainer.childElementCount).toBe(2);
            expect(lightboxContainer).toBeInTheDocument();

            // Header exists in the lightbox
            expect(lightboxHeader).toBeInTheDocument();
        });
    });

    describe('renderFooter', () => {
        test('renders custom footer', () => {
            render(
                <Lightbox
                    renderFooter={() => <footer data-testid="footer" />}
                />,
            );

            const lightboxContainer = screen.getByTestId('lightbox-container');
            const lightboxFooter = screen.getByTestId('footer');

            // Lightbox container exists and should have a pager body and footer
            expect(lightboxContainer.childElementCount).toBe(2);
            expect(lightboxContainer).toBeInTheDocument();

            // Footer exists in the lightbox
            expect(lightboxFooter).toBeInTheDocument();
        });
    });

    describe('renderNextButton/renderPrevButton', () => {
        test('renders custom prev/next buttons', () => {
            render(
                <Lightbox
                    // eslint-disable-next-line jsx-a11y/control-has-associated-label
                    renderNextButton={() => (
                        <button data-testid="next-button" type="button" />
                    )}
                    // eslint-disable-next-line jsx-a11y/control-has-associated-label
                    renderPrevButton={() => (
                        <button data-testid="prev-button" type="button" />
                    )}
                />,
            );

            const prevButton = screen.getByTestId('next-button');
            const nextButton = screen.getByTestId('prev-button');

            expect(prevButton).toBeInTheDocument();
            expect(nextButton).toBeInTheDocument();
        });
    });

    describe('keyboard shortcuts', () => {
        test('calls onNext() callback on ArrowRight keypress', () => {
            const onNext = jest.fn();
            render(<Lightbox onNext={onNext} />);

            fireEvent.keyDown(document, { code: 39, key: 'ArrowRight' });
            fireEvent.keyUp(document, { code: 39, key: 'ArrowRight' });

            expect(onNext).toHaveBeenCalledTimes(1);
        });

        test('calls onPrev() callback on ArrowLeft keypress', () => {
            const onPrev = jest.fn();
            render(<Lightbox currentIndex={2} onPrev={onPrev} />);

            fireEvent.keyDown(document, { code: 37, key: 'ArrowLeft' });
            fireEvent.keyUp(document, { code: 37, key: 'ArrowLeft' });

            expect(onPrev).toHaveBeenCalledTimes(1);
        });

        test('calls onClose() callback on Esc keypress', () => {
            const onClose = jest.fn();
            render(<Lightbox onClose={onClose} />);

            fireEvent.keyDown(document, { code: 27, key: 'Escape' });
            fireEvent.keyUp(document, { code: 27, key: 'Escape' });

            expect(onClose).toHaveBeenCalledTimes(1);
        });
    });
});


================================================
FILE: src/components/CreatePortal/index.tsx
================================================
import React from 'react';
import ReactDOM from 'react-dom';

type ICreatePortal = {
    children: any;
};

/**
 * Creates a SSR + next.js friendly React Portal inside <body />
 *
 * Child components are rendered on the client side only

 * @see https://reactjs.org/docs/portals.html
 */
class CreatePortal extends React.Component<ICreatePortal> {
    portalContainer: HTMLDivElement;
    body: HTMLElement;

    // Only executes on the client-side
    componentDidMount() {
        // Get the document body
        this.body = document.body;

        // Create a container <div /> for React Portal
        this.portalContainer = document.createElement('div');
        this.portalContainer.setAttribute('class', 'lightbox-portal');

        // Append the container to the document body
        this.body.appendChild(this.portalContainer);

        // Force a re-render as we're on the client side now
        // children prop will render to portalContainer
        this.forceUpdate();

        // Add event listener to prevent trackpad/ctrl+mousewheel zooming of lightbox
        // Zooming is handled specifically within /ImageStage/components/Image
        this.portalContainer.addEventListener('wheel', this.preventWheel);
    }

    componentWillUnmount() {
        // Remove wheel event listener
        this.portalContainer.removeEventListener('wheel', this.preventWheel);

        // Cleanup Portal from DOM
        this.body.removeChild(this.portalContainer);
    }

    preventWheel = (e: WheelEvent) => e.preventDefault();

    render() {
        // Return null during SSR
        if (this.portalContainer === undefined) return null;

        const { children } = this.props;

        return <>{ReactDOM.createPortal(children, this.portalContainer)}</>;
    }
}

export default CreatePortal;


================================================
FILE: src/components/ImageStage/components/Image/index.tsx
================================================
import { animated, to, useSpring } from '@react-spring/web';
import {
    getTranslateOffsetsFromScale,
    imageIsOutOfBounds,
    useDoubleClick,
} from '../../utils';
import { useGesture } from 'react-use-gesture';
import React, { useEffect, useRef, useState } from 'react';
import styled, { AnyStyledComponent } from 'styled-components';
import type { ImagesListItem } from '../../../../types/ImagesList';

const defaultImageTransform = {
    pinching: false,
    scale: 1,
    translateX: 0,
    translateY: 0,
};

type IImageProps = {
    /** Any valid <img /> props to pass to the lightbox img element ie src, alt, caption etc*/
    imgProps: ImagesListItem;
    /** Affects Width calculation method, depending on whether the Lightbox is Inline or not */
    inline: boolean;
    /** True if this image is currently shown in pager, otherwise false */
    isCurrentImage: boolean;
    /** Fixed height of the image stage, used to restrict maximum height of images */
    pagerHeight: '100%' | number;
    /** Indicates parent ImagePager is in a state of dragging, if true click to zoom is disabled */
    pagerIsDragging: boolean;
    /** Function that can be called to disable dragging in the pager */
    setDisableDrag: (disable: boolean) => void;
    /** Overrides the default behavior of double clicking causing an image zoom to a single click */
    singleClickToZoom: boolean;
};

/**
 * Animates pinch-zoom + panning on image using spring physics
 */
const Image = ({
    imgProps: { style: imgStyleProp, ...restImgProps },
    inline,
    isCurrentImage,
    pagerHeight,
    pagerIsDragging,
    setDisableDrag,
    singleClickToZoom,
}: IImageProps) => {
    const [isPanningImage, setIsPanningImage] = useState<boolean>(false);
    const imageRef = useRef<HTMLImageElement>(null);

    /**
     * Animates scale and translate offsets of Image as they change in gestures
     *
     * @see https://www.react-spring.io/docs/hooks/use-spring
     */
    const [{ scale, translateX, translateY }, springApi] = useSpring(() => ({
        ...defaultImageTransform,
        onChange: (result, instance) => {
            if (result.value.scale < 1 || !result.value.pinching) {
                instance.start(defaultImageTransform);
            }

            if (result.value.scale > 1 && imageIsOutOfBounds(imageRef)) {
                instance.start(defaultImageTransform);
            }
        },
        // Enable dragging in ImagePager if image is at the default size
        onRest: (result, instance) => {
            if (result.value.scale === 1) {
                instance.start(defaultImageTransform);
                setDisableDrag(false);
            }
        },
    }));

    // Reset scale of this image when dragging to new image in ImagePager
    useEffect(() => {
        if (!isCurrentImage && scale.get() !== 1) {
            springApi.start(defaultImageTransform);
        }
    }, [isCurrentImage, scale, springApi]);

    /**
     * Update Image scale and translate offsets during pinch/pan gestures
     *
     * @see https://github.com/react-spring/react-use-gesture#usegesture-hook-supporting-multiple-gestures-at-once
     */
    useGesture(
        {
            onDrag: ({
                cancel,
                first,
                memo = { initialTranslateX: 0, initialTranslateY: 0 },
                movement: [xMovement, yMovement],
                pinching,
                tap,
                touches,
            }) => {
                if (pagerIsDragging || scale.get() === 1 || tap) {
                    return;
                }

                // Disable click to zoom during drag
                if (xMovement && yMovement && !isPanningImage) {
                    setIsPanningImage(true);
                }

                if (touches > 1) {
                    return;
                }
                if (pinching || scale.get() <= 1) {
                    return;
                }

                // Prevent dragging image out of viewport
                if (scale.get() > 1 && imageIsOutOfBounds(imageRef)) {
                    cancel();
                    return;
                } else {
                    if (first) {
                        return {
                            initialTranslateX: translateX.get(),
                            initialTranslateY: translateY.get(),
                        };
                    }

                    // Translate image from dragging
                    springApi.start({
                        translateX: memo.initialTranslateX + xMovement,
                        translateY: memo.initialTranslateY + yMovement,
                    });

                    return memo;
                }
            },
            onDragEnd: ({ memo }) => {
                if (memo !== undefined) {
                    // Add small timeout to prevent onClick handler from firing after drag
                    setTimeout(() => setIsPanningImage(false), 100);
                }
            },
            onPinch: ({
                cancel,
                ctrlKey,
                event,
                last,
                movement: [xMovement],
                origin: [touchOriginX, touchOriginY],
            }) => {
                if (pagerIsDragging) {
                    return;
                }

                // Prevent ImagePager from registering isDragging
                setDisableDrag(true);

                // Disable click to zoom during pinch
                if (xMovement && !isPanningImage) {
                    setIsPanningImage(true);
                }

                // Don't calculate new translate offsets on final frame
                if (last) {
                    cancel();
                    return;
                }

                // Speed up pinch zoom when using mouse versus touch
                const SCALE_FACTOR = ctrlKey ? 1000 : 250;
                const pinchScale = scale.get() + xMovement / SCALE_FACTOR;
                const pinchDelta = pinchScale - scale.get();

                /**
                 * Calculate touch origin for pinch/zoom
                 *
                 * if event is a touch event (React.TouchEvent<Element>, TouchEvent or WebKitGestureEvent) use touchOriginX/Y
                 * if event is a wheel event (React.WheelEvent<Element> or WheelEvent) use the mouse cursor's clientX/Y
                 */
                let touchOrigin: [touchOriginX: number, touchOriginY: number] =
                    [touchOriginX, touchOriginY];
                if ('clientX' in event && 'clientY' in event && ctrlKey) {
                    touchOrigin = [event.clientX, event.clientY];
                }

                // Calculate the amount of x, y translate offset needed to
                // zoom-in to point as image scale grows
                const [newTranslateX, newTranslateY] =
                    getTranslateOffsetsFromScale({
                        currentTranslate: [translateX.get(), translateY.get()],
                        imageRef,
                        pinchDelta,
                        scale: scale.get(),
                        // Use the [x, y] coords of mouse if a trackpad or ctrl + wheel event
                        // Otherwise use touch origin
                        touchOrigin,
                    });

                // Restrict the amount of zoom between half and 3x image size
                if (pinchScale < 0.5) {
                    springApi.start({ pinching: true, scale: 0.5 });
                } else if (pinchScale > 3.0) {
                    springApi.start({ pinching: true, scale: 3.0 });
                } else {
                    springApi.start({
                        pinching: true,
                        scale: pinchScale,
                        translateX: newTranslateX,
                        translateY: newTranslateY,
                    });
                }
            },
            onPinchEnd: () => {
                if (!pagerIsDragging) {
                    if (scale.get() > 1) {
                        setDisableDrag(true);
                    } else {
                        springApi.start(defaultImageTransform);
                    }
                    // Add small timeout to prevent onClick handler from firing after panning
                    setTimeout(() => setIsPanningImage(false), 100);
                }
            },
        },
        /**
         * useGesture config
         * @see https://github.com/react-spring/react-use-gesture#usegesture-config
         */
        {
            domTarget: imageRef as React.RefObject<EventTarget>,
            drag: {
                filterTaps: true,
            },
            enabled: !inline,
            eventOptions: {
                passive: false,
            },
        },
    );

    // Handle click/tap on image
    useDoubleClick({
        [singleClickToZoom ? 'onSingleClick' : 'onDoubleClick']: (
            e: MouseEvent,
        ) => {
            if (pagerIsDragging || isPanningImage) {
                e.stopPropagation();
                return;
            }

            // If tapped while already zoomed-in, zoom out to default scale
            if (scale.get() !== 1) {
                springApi.start(defaultImageTransform);
                return;
            }

            // Zoom-in to origin of click on image
            const { clientX: touchOriginX, clientY: touchOriginY } = e;
            const pinchScale = scale.get() + 1;
            const pinchDelta = pinchScale - scale.get();

            // Calculate the amount of x, y translate offset needed to
            // zoom-in to point as image scale grows
            const [newTranslateX, newTranslateY] = getTranslateOffsetsFromScale(
                {
                    currentTranslate: [translateX.get(), translateY.get()],
                    imageRef,
                    pinchDelta,
                    scale: scale.get(),
                    touchOrigin: [touchOriginX, touchOriginY],
                },
            );

            // Disable dragging in pager
            setDisableDrag(true);
            springApi.start({
                pinching: true,
                scale: pinchScale,
                translateX: newTranslateX,
                translateY: newTranslateY,
            });
        },
        enabled: !inline,
        latency: singleClickToZoom ? 0 : 200,
        ref: imageRef,
    });

    return (
        <AnimatedImage
            $inline={inline}
            className="lightbox-image"
            draggable="false"
            onClick={(e: React.MouseEvent<HTMLImageElement>) => {
                // Don't close lighbox when clicking image
                e.stopPropagation();
                e.nativeEvent.stopImmediatePropagation();
            }}
            onDragStart={(e: React.DragEvent<HTMLImageElement>) => {
                // Disable image ghost dragging in firefox
                e.preventDefault();
            }}
            ref={imageRef}
            style={{
                ...imgStyleProp,
                maxHeight: pagerHeight,
                transform: to(
                    [scale, translateX, translateY],
                    (s, x, y) => `translate(${x}px, ${y}px) scale(${s})`,
                ),
                ...(isCurrentImage && { willChange: 'transform' }),
            }}
            // Include any valid img html attributes provided in the <Lightbox /> images prop
            {...(restImgProps as React.ComponentProps<typeof animated.img>)}
        />
    );
};

Image.displayName = 'Image';

export default Image;

const AnimatedImage = styled(animated.img as AnyStyledComponent)`
    width: auto;
    height: auto;
    max-width: 100%;
    user-select: none;
    touch-action: ${({ $inline }) => (!$inline ? 'none' : 'pan-y')};
    ::selection {
        background: none;
    }
`;


================================================
FILE: src/components/ImageStage/components/ImagePager/index.tsx
================================================
import { animated, useSprings } from '@react-spring/web';
import { useGesture } from 'react-use-gesture';
import Image from '../Image';
import React, { useEffect, useRef, useState } from 'react';
import styled, { AnyStyledComponent } from 'styled-components';
import type { ImagesList } from '../../../../types/ImagesList';

type IImagePager = {
    /** Index of image in images array that is currently shown */
    currentIndex: number;
    /** image stage height */
    imageStageHeight: number;
    /** image stage width */
    imageStageWidth: number;
    /** Array of image objects to be shown in Lightbox */
    images: ImagesList;
    /** Affects Width calculation method, depending on whether the Lightbox is Inline or not */
    inline: boolean;
    /** Function that closes the Lightbox */
    onClose?: () => void;
    /** Function that can be called to disable dragging in the pager */
    onNext: () => void;
    /** True if this image is currently shown in pager, otherwise false */
    onPrev: () => void;
    /** A React component that renders inside the image stage, useful for making overlays over the image */
    renderImageOverlay: () => React.ReactNode;
    /** Overrides the default behavior of double clicking causing an image zoom to a single click */
    singleClickToZoom: boolean;
};

/**
 * Gesture controlled surface that animates prev/next page changes via spring physics.
 */
const ImagePager = ({
    currentIndex,
    images,
    imageStageHeight,
    imageStageWidth,
    inline,
    onClose,
    onNext,
    onPrev,
    renderImageOverlay,
    singleClickToZoom,
}: IImagePager) => {
    const firstRender = useRef(true);

    const [disableDrag, setDisableDrag] = useState<boolean>(false);
    const [pagerHeight, setPagerHeight] = useState<'100%' | number>('100%');
    const [isDragging, setIsDragging] = useState<boolean>(false);

    //Determine the absolute height of the image pager
    useEffect(() => {
        const currPagerHeight = inline
            ? imageStageHeight
            : imageStageHeight - 50;

        if (currPagerHeight !== pagerHeight) {
            setPagerHeight(currPagerHeight);
        }
    }, [inline, pagerHeight, imageStageHeight]);

    // Generate page positions based on current index
    const getPagePositions = React.useCallback(
        (i: number, down = false, xDelta = 0) => {
            const x =
                (i - currentIndex) * imageStageWidth + (down ? xDelta : 0);

            if (i < currentIndex - 1 || i > currentIndex + 1) {
                return { display: 'none', x };
            }
            return { display: 'flex', x };
        },
        [currentIndex, imageStageWidth],
    );

    /**
     * Animates translateX of all images at the same time
     *
     * @see https://www.react-spring.io/docs/hooks/use-springs
     */
    const [pagerSprings, springsApi] = useSprings(images.length, (i) =>
        getPagePositions(i),
    );

    // Animate page change if currentIndex changes
    useEffect(() => {
        // No need to set page position for initial render
        if (firstRender.current) {
            firstRender.current = false;
            return;
        }
        // Update page positions after prev/next page state change
        springsApi.start((i) => getPagePositions(i));
    }, [currentIndex, getPagePositions, springsApi]);

    /**
     * Update each Image's visibility and translateX offset during dragging
     *
     * @see https://github.com/react-spring/react-use-gesture
     */
    const bind = useGesture(
        {
            onDrag: ({
                active,
                cancel,
                direction: [xDir],
                distance,
                down,
                movement: [xMovement],
                tap,
                touches,
                velocity,
            }) => {
                // Disable drag if Image has been zoomed in to allow for panning
                if (disableDrag || xMovement === 0 || tap) {
                    return;
                }
                if (!isDragging) {
                    setIsDragging(true);
                }

                const isHorizontalDrag = Math.abs(xDir) > 0.7;
                const draggedFarEnough =
                    down &&
                    isHorizontalDrag &&
                    distance > imageStageWidth / 3.5;
                const draggedFastEnough =
                    down && isHorizontalDrag && velocity > 2;

                // Handle next/prev image from valid drag
                if ((draggedFarEnough || draggedFastEnough) && active) {
                    const goToIndex = xDir > 0 ? -1 : 1;

                    // Cancel gesture event
                    cancel();

                    if (goToIndex > 0) {
                        onNext();
                    } else if (goToIndex < 0) {
                        onPrev();
                    }

                    return;
                }

                // Don't move pager during two+ finger touch events, i.e. pinch-zoom
                if (touches > 1) {
                    cancel();
                    return;
                }

                // Update page x-coordinates for single finger/mouse gestures
                springsApi.start((i) => getPagePositions(i, down, xMovement));
                return;
            },
            onDragEnd: () => {
                if (isDragging) {
                    springsApi.start((i) => getPagePositions(i));
                    // Add small timeout buffer to prevent event handlers from firing in child Images
                    setTimeout(() => setIsDragging(false), 100);
                }
            },
            onWheel: ({ ctrlKey, direction: [xDir, yDir], velocity }) => {
                // Disable drag if Image has been zoomed in to allow for panning
                if (ctrlKey || disableDrag || velocity === 0) {
                    return;
                }

                if (!isDragging) {
                    setIsDragging(true);
                }

                const draggedFastEnough = velocity > 1.1;

                // Handle next/prev image from valid drag
                if (draggedFastEnough) {
                    const goToIndex = xDir + yDir > 0 ? -1 : 1;

                    if (goToIndex > 0) {
                        onNext();
                    } else if (goToIndex < 0) {
                        onPrev();
                    }
                }
            },
            onWheelEnd: () => {
                springsApi.start((i) => getPagePositions(i));
                // Add small timeout buffer to prevent event handlers from firing in child Images
                setTimeout(() => setIsDragging(false), 100);
            },
        },
        {
            drag: {
                filterTaps: true,
            },
            wheel: {
                enabled: !inline,
            },
        },
    );

    return (
        <ImagePagerContainer>
            {pagerSprings.map(({ display, x }, i) => (
                <AnimatedImagePager
                    $inline={inline}
                    {...bind()}
                    className="lightbox-image-pager"
                    key={i}
                    onClick={() => {
                        if (onClose) {
                            return (
                                Math.abs(x.get()) < 1 &&
                                !disableDrag &&
                                onClose()
                            );
                        }
                    }}
                    role="presentation"
                    style={{
                        display,
                        transform: x.to(
                            (xInterp: number) => `translateX(${xInterp}px)`,
                        ),
                    }}
                >
                    <PagerContentWrapper>
                        <PagerInnerContentWrapper>
                            <ImageContainer
                                $inline={inline}
                                onClick={(e) => {
                                    e.stopPropagation();
                                    e.nativeEvent.stopImmediatePropagation();
                                }}
                            >
                                <Image
                                    imgProps={images[i]}
                                    inline={inline}
                                    isCurrentImage={i === currentIndex}
                                    pagerHeight={pagerHeight}
                                    pagerIsDragging={isDragging}
                                    setDisableDrag={setDisableDrag}
                                    singleClickToZoom={singleClickToZoom}
                                />
                                {renderImageOverlay()}
                            </ImageContainer>
                        </PagerInnerContentWrapper>
                    </PagerContentWrapper>
                </AnimatedImagePager>
            ))}
        </ImagePagerContainer>
    );
};

ImagePager.displayName = 'ImagePager';

export default ImagePager;

const ImagePagerContainer = styled.div`
    height: 100%;
    width: 100%;
`;

const PagerInnerContentWrapper = styled.div`
    display: flex;
    justify-content: center;
    align-items: center;
`;

const PagerContentWrapper = styled.div`
    width: 100%;
    display: flex;
    justify-content: center;
`;

const AnimatedImagePager = styled(animated.span as AnyStyledComponent)<{
    $inline: boolean;
}>`
    position: absolute;
    top: 0px;
    left: 0px;
    right: 0px;
    bottom: 0px;
    height: 100%;
    width: 100%;
    will-change: transform;
    touch-action: ${({ $inline }) => (!$inline ? 'none' : 'pan-y')};
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
`;

const ImageContainer = styled.div<{ $inline: boolean }>`
    position: relative;
    touch-action: ${({ $inline }) => (!$inline ? 'none' : 'pan-y')};
    user-select: none;
    display: flex;
    justify-content: center;
    width: 100%;
`;


================================================
FILE: src/components/ImageStage/components/SSRImagePager/SSRImagePager.tsx
================================================
import type { ImagesList } from '../../../../types/ImagesList';
import styled, { css } from 'styled-components';
import * as React from 'react';

type ISSRImagePagerProps = {
    currentIndex: number;
    images: ImagesList;
};

const SSRImagePager = ({ currentIndex, images }: ISSRImagePagerProps) => {
    return (
        <ImagePagerContainer>
            {images.map(({ alt, src }, i) => {
                return (
                    <Image
                        $isCurrentImage={i === currentIndex}
                        alt={alt}
                        key={`${alt}-${src}-${i}`}
                        src={src}
                    />
                );
            })}
        </ImagePagerContainer>
    );
};

export default SSRImagePager;

const ImagePagerContainer = styled.div`
    width: 100%;
    height: inherit;
`;

const Image = styled.img<{ $isCurrentImage: boolean }>`
    ${({ $isCurrentImage }) =>
        !$isCurrentImage &&
        css`
            visibility: hidden;
            display: none;
        `}
    height:100%;
    width: 100%;
    object-fit: contain;
`;


================================================
FILE: src/components/ImageStage/index.tsx
================================================
import ImagePager from './components/ImagePager';
import React from 'react';
import styled from 'styled-components';
import useRefSize from './utils/useRefSize';
import type { ImagesList } from '../../types/ImagesList';
import SSRImagePager from './components/SSRImagePager/SSRImagePager';

type IImageStageProps = {
    /** classnames are applied to the root ImageStage component */
    className?: string;
    /** Index of image in images array that is currently shown */
    currentIndex: number;
    /** Array of image objects to be shown in Lightbox */
    images: ImagesList;
    /** Affects Width calculation method, depending on whether the Lightbox is Inline or not */
    inline: boolean;
    /** Function that closes the Lightbox */
    onClose?: () => void;
    /** Function that can be called to disable dragging in the pager */
    onNext: () => void;
    /** True if this image is currently shown in pager, otherwise false */
    onPrev: () => void;
    /** A React component that renders inside the image stage, useful for making overlays over the image */
    renderImageOverlay: () => React.ReactNode;
    /** A React component that is used for next button in image pager */
    renderNextButton: ({ canNext }: { canNext: boolean }) => React.ReactNode;
    /** A React component that is used for previous button in image pager */
    renderPrevButton: ({ canPrev }: { canPrev: boolean }) => React.ReactNode;
    /** Overrides the default behavior of double clicking causing an image zoom to a single click */
    singleClickToZoom: boolean;
};

/**
 * Containing element for ImagePager and prev/next button controls
 */
const ImageStage = ({
    className = '',
    currentIndex,
    images,
    inline,
    onClose,
    onNext,
    onPrev,
    renderImageOverlay,
    renderNextButton,
    renderPrevButton,
    singleClickToZoom,
}: IImageStageProps) => {
    // Extra sanity check that the next/prev image exists before moving to it
    const canPrev = currentIndex > 0;
    const canNext = currentIndex + 1 < images.length;

    const onNextImage = canNext ? onNext : () => null;
    const onPrevImage = canPrev ? onPrev : () => null;

    const [{ height: containerHeight, width: containerWidth }, containerRef] =
        useRefSize();

    return (
        <ImageStageContainer
            className={className}
            data-testid="lightbox-image-stage"
            ref={containerRef}
        >
            {renderPrevButton({ canPrev })}
            {containerWidth ? (
                <ImagePager
                    currentIndex={currentIndex}
                    images={images}
                    imageStageHeight={containerHeight}
                    imageStageWidth={containerWidth}
                    inline={inline}
                    onClose={onClose}
                    onNext={onNextImage}
                    onPrev={onPrevImage}
                    renderImageOverlay={renderImageOverlay}
                    singleClickToZoom={singleClickToZoom}
                />
            ) : inline ? (
                <SSRImagePager currentIndex={currentIndex} images={images} />
            ) : null}
            {renderNextButton({ canNext })}
        </ImageStageContainer>
    );
};

export default ImageStage;

const ImageStageContainer = styled.div`
    position: relative;
    height: 100%;
    width: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
`;


================================================
FILE: src/components/ImageStage/utils/getTranslateOffsetsFromScale.ts
================================================
type IGetTranslateOffsetsFromScale = {
    /** The current [x,y] translate values of image */
    currentTranslate: [translateX: number, translateY: number];
    /** The image dom node used as a reference to calculate translate offsets */
    imageRef: React.RefObject<HTMLImageElement>;
    /** The amount of change in the new transform scale */
    pinchDelta: number;
    /** The current transform scale of image */
    scale: number;
    /** The [x,y] coordinates of the zoom origin */
    touchOrigin: [touchOriginX: number, touchOriginY: number];
};

type ITranslateOffsetsReturnType = [translateX: number, translateY: number];

/**
 * Calculates the the translate(x,y) coordinates needed to zoom-in
 * to a point in an image.
 *
 * @returns {array} The next [x,y] translate values to apply to image
 */
const getTranslateOffsetsFromScale = ({
    currentTranslate: [translateX, translateY],
    imageRef,
    pinchDelta,
    scale,
    touchOrigin: [touchOriginX, touchOriginY],
}: IGetTranslateOffsetsFromScale): ITranslateOffsetsReturnType => {
    if (!imageRef?.current) {
        return [0, 0];
    }

    const {
        height: imageHeight,
        left: imageTopLeftX,
        top: imageTopLeftY,
        width: imageWidth,
    } = imageRef.current?.getBoundingClientRect();

    // Get the (x,y) touch position relative to image origin at the current scale
    const imageCoordX = (touchOriginX - imageTopLeftX - imageWidth / 2) / scale;
    const imageCoordY =
        (touchOriginY - imageTopLeftY - imageHeight / 2) / scale;

    // Calculate translateX/Y offset at the next scale to zoom to touch position
    const newTranslateX = -imageCoordX * pinchDelta + translateX;
    const newTranslateY = -imageCoordY * pinchDelta + translateY;

    return [newTranslateX, newTranslateY];
};

export default getTranslateOffsetsFromScale;


================================================
FILE: src/components/ImageStage/utils/imageIsOutOfBounds.ts
================================================
/**
 * Determines if the provided image is within the viewport
 *
 * @returns True if image needs to be resized to fit viewport, otherwise false
 */
const imageIsOutOfBounds = (
    imageRef: React.RefObject<HTMLImageElement>,
): boolean => {
    // If no ref is provided, return false
    if (!imageRef.current) {
        return false;
    }

    const {
        bottom: bottomRightY,
        left: topLeftX,
        right: bottomRightX,
        top: topLeftY,
    } = imageRef.current?.getBoundingClientRect();
    const { innerHeight: windowHeight, innerWidth: windowWidth } = window;

    if (
        topLeftX > windowWidth * (1 / 2) ||
        topLeftY > windowHeight * (1 / 2) ||
        bottomRightX < windowWidth * (1 / 2) ||
        bottomRightY < windowHeight * (1 / 2)
    )
        return true;

    return false;
};

export default imageIsOutOfBounds;


================================================
FILE: src/components/ImageStage/utils/index.ts
================================================
import getTranslateOffsetsFromScale from './getTranslateOffsetsFromScale';
import imageIsOutOfBounds from './imageIsOutOfBounds';
import useDoubleClick from './useDoubleClick';
import useWindowSize from './useRefSize';

export {
    getTranslateOffsetsFromScale,
    imageIsOutOfBounds,
    useDoubleClick,
    useWindowSize,
};


================================================
FILE: src/components/ImageStage/utils/useDoubleClick.tsx
================================================
import React, { useEffect } from 'react';

type IUseDoubleClickProps = {
    /** Set to false to disable onDoubleClick/onSingleClick  */
    enabled?: boolean;
    /** The amount of time (in milliseconds) to wait before differentiating a single from a double click */
    latency?: number;
    /** A callback function for double click events */
    onDoubleClick?: (event: MouseEvent) => void;
    /** A callback function for single click events */
    onSingleClick?: (event: MouseEvent) => void;
    /** Dom node to watch for double clicks */
    ref: React.RefObject<HTMLElement>;
};

/**
 * React Hook that returns the current window size
 * and report updates from the 'resize' window event
 */
const useDoubleClick = ({
    enabled = true,
    latency = 300,
    onDoubleClick = () => null,
    onSingleClick = () => null,
    ref,
}: IUseDoubleClickProps) => {
    useEffect(() => {
        const clickRef = ref.current;
        let clickCount = 0;
        let timer: ReturnType<typeof setTimeout>;

        const handleClick = (e: MouseEvent) => {
            if (enabled) {
                clickCount += 1;

                timer = setTimeout(() => {
                    if (clickCount === 1) onSingleClick(e);
                    else if (clickCount === 2) onDoubleClick(e);

                    clickCount = 0;
                }, latency);
            }
        };

        // Add event listener for click events
        clickRef?.addEventListener('click', handleClick);

        // Remove event listener
        return () => {
            clickRef?.removeEventListener('click', handleClick);

            if (timer) {
                clearTimeout(timer);
            }
        };
    });
};

export default useDoubleClick;


================================================
FILE: src/components/ImageStage/utils/useRefSize.tsx
================================================
import { useCallback, useEffect, useRef, useState } from 'react';

type RefSize = {
    height: number;
    width: number;
};

type Node = HTMLDivElement | null;

type IUseRefSize = [refSize: RefSize, elementRef: (node: any) => void | null];

/**
 * React Hook that returns the current ref size
 * and report updates from the 'resize' ref event
 *
 * @returns {RefSize} An object containing the ref width and height
 * @returns {elementRef} A callback ref to be used on the container being measured
 */
const useRefSize = (): IUseRefSize => {
    const ref = useRef<HTMLDivElement>(null);

    const [node, setNode] = useState<Node>(null);
    const [refSize, setRefSize] = useState<RefSize>({
        height: ref.current?.clientHeight || 0,
        width: ref.current?.clientWidth || 0,
    });

    const elementRef = useCallback((node: Node) => {
        if (node !== null) {
            setNode(node);

            setRefSize({
                height: node.clientHeight,
                width: node.clientWidth,
            });
        }
    }, []);

    useEffect(() => {
        const handleResize = () => {
            if (node) {
                const height = node.clientHeight;
                const width = node.clientWidth;
                if (height !== refSize.height || width !== refSize.width) {
                    setRefSize({
                        height,
                        width,
                    });
                }
            }
        };

        window.addEventListener('resize', handleResize);
        window.addEventListener('orientationchange', handleResize);

        return () => {
            window.removeEventListener('resize', handleResize);
            window.removeEventListener('orientationchange', handleResize);
        };
    }, [node, refSize.height, refSize.width]);

    return [refSize, elementRef];
};

export default useRefSize;


================================================
FILE: src/components/PageContainer/index.tsx
================================================
import React from 'react';
import { useTransition, animated, config } from '@react-spring/web';
import styled, { AnyStyledComponent } from 'styled-components';

type IPageContainerProps = {
    /** All child components of Lightbox */
    children: React.ReactNode[];
    /** Classes are applied to the root lightbox component */
    className: string;
    /** Flag that dictates if the lightbox is open or closed */
    isOpen: boolean;
    /** React-Spring useTransition config for page open/close animation */
    pageTransitionConfig: any;
    /** Inline styles are applied to the root lightbox component */
    style: React.CSSProperties;
};

/**
 * Animates the lightbox as it opens/closes
 */
const PageContainer = ({
    children,
    className,
    isOpen,
    pageTransitionConfig,
    style,
}: IPageContainerProps) => {
    const defaultTransition = {
        config: { ...config.default, friction: 32, mass: 1, tension: 320 },
        enter: { opacity: 1, transform: 'scale(1)' },
        from: { opacity: 0, transform: 'scale(0.75)' },
        leave: { opacity: 0, transform: 'scale(0.75)' },
    };

    const transitions = useTransition(isOpen, {
        ...defaultTransition,
        ...pageTransitionConfig,
    });

    return (
        <>
            {transitions(
                (animatedStyles, item) =>
                    item && (
                        <AnimatedPageContainer
                            className={`lightbox-container${
                                className ? ` ${className}` : ''
                            }`}
                            data-testid="lightbox-container"
                            style={{ ...animatedStyles, ...style }}
                        >
                            {children}
                        </AnimatedPageContainer>
                    ),
            )}
        </>
    );
};

export default PageContainer;

const AnimatedPageContainer = styled(animated.div as AnyStyledComponent)`
    display: flex;
    flex-direction: column;
    position: fixed;
    z-index: 400;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
`;


================================================
FILE: src/components/index.tsx
================================================
import ImageStage from './ImageStage';
import PageContainer from './PageContainer';
import CreatePortal from './CreatePortal';

export { ImageStage, PageContainer, CreatePortal };


================================================
FILE: src/index.tsx
================================================
import React, { useEffect } from 'react';
import { ImageStage, PageContainer, CreatePortal } from './components';
import type { ImagesList } from './types/ImagesList';

export type ImagesListType = ImagesList;

type ILightboxProps = {
    /** classnames are applied to the root lightbox component */
    className?: string;
    /** Index of image in images array that is currently shown */
    currentIndex: number;
    /** Array of images to be shown in Lightbox, each image object may contain any valid 'img' attribute with the exceptions of 'draggable', 'onClick', 'onDragStart' and 'ref' */
    images: ImagesList;
    /** Determines whether the Lightbox returns just an Inline carousel (ImageStage) */
    inline?: boolean;
    /** Flag that dictates if the lightbox is open or closed */
    isOpen: boolean;
    /** Function that closes the Lightbox */
    onClose?: () => void;
    /** Function that changes currentIndex to next image in images */
    onNext: () => void;
    /** Function that changes currentIndex to previous image in images */
    onPrev: () => void;
    /** React-Spring useTransition config for page open/close animation */
    pageTransitionConfig?: any;
    /** A React component that renders below the image pager */
    renderFooter?: () => React.ReactNode;
    /** A React component that renders above the image pager */
    renderHeader?: () => React.ReactNode;
    /** A React component that renders inside the image stage, useful for making overlays over the image */
    renderImageOverlay?: () => React.ReactNode;
    /** A React component that is used for next button in image pager */
    renderNextButton?: ({ canNext }: { canNext: boolean }) => React.ReactNode;
    /** A React component that is used for previous button in image pager */
    renderPrevButton?: ({ canPrev }: { canPrev: boolean }) => React.ReactNode;
    /** Overrides the default behavior of double clicking causing an image zoom to a single click */
    singleClickToZoom?: boolean;
    /** Inline styles that are applied to the root lightbox component */
    style?: React.CSSProperties;
};

/**
 * Gesture controlled lightbox that interpolates animations with spring physics.
 *
 * Demos and docs:
 * @see https://timellenberger.com/libraries/react-spring-lightbox
 *
 * GitHub repo:
 * @see https://github.com/tim-soft/react-spring-lightbox
 *
 * Built with:
 * @see https://github.com/react-spring/react-use-gesture
 * @see https://github.com/react-spring/react-spring
 * @see https://github.com/styled-components/styled-components
 */
const Lightbox = ({
    className = '',
    currentIndex,
    images = [],
    inline = false,
    isOpen,
    onClose,
    onNext,
    onPrev,
    pageTransitionConfig = null,
    renderFooter = () => null,
    renderHeader = () => null,
    renderImageOverlay = () => null,
    renderNextButton = () => null,
    renderPrevButton = () => null,
    singleClickToZoom = false,
    style = {},
}: ILightboxProps) => {
    // Handle event listeners for keyboard
    useEffect(() => {
        /**
         * Prevent keyboard from controlling background page
         * when lightbox is open
         */
        const preventBackgroundScroll = (e: KeyboardEvent) => {
            const keysToIgnore = [
                'ArrowUp',
                'ArrowDown',
                'End',
                'Home',
                'PageUp',
                'PageDown',
            ];

            if (isOpen && keysToIgnore.includes(e.key)) e.preventDefault();
        };

        /**
         * Navigate images with arrow keys, close on Esc key
         */
        const handleKeyboardInput = (e: KeyboardEvent) => {
            if (isOpen) {
                switch (e.key) {
                    case 'ArrowLeft':
                        onPrev();
                        break;
                    case 'ArrowRight':
                        onNext();
                        break;
                    case 'Escape':
                        onClose && onClose();
                        break;
                    default:
                        e.preventDefault();
                        break;
                }
            }
        };

        document.addEventListener('keyup', handleKeyboardInput);
        document.addEventListener('keydown', preventBackgroundScroll);

        return () => {
            document.removeEventListener('keyup', handleKeyboardInput);
            document.removeEventListener('keydown', preventBackgroundScroll);
        };
    });

    const imageStage = (
        <ImageStage
            currentIndex={currentIndex}
            images={images}
            inline={inline}
            onClose={onClose}
            onNext={onNext}
            onPrev={onPrev}
            renderImageOverlay={renderImageOverlay}
            renderNextButton={renderNextButton}
            renderPrevButton={renderPrevButton}
            singleClickToZoom={singleClickToZoom}
        />
    );

    if (inline) {
        return imageStage;
    }

    return (
        <CreatePortal>
            <PageContainer
                className={className}
                isOpen={isOpen}
                pageTransitionConfig={pageTransitionConfig}
                style={style}
            >
                {renderHeader()}
                {imageStage}
                {renderFooter()}
            </PageContainer>
        </CreatePortal>
    );
};

export default Lightbox;


================================================
FILE: src/types/ImagesList.ts
================================================
export type ImagesListItem = Omit<
    React.HTMLProps<HTMLImageElement>,
    'draggable' | 'onClick' | 'onDragStart' | 'ref'
> & { alt: string; loading?: 'auto' | 'eager' | 'lazy'; src: string };

export type ImagesList = ImagesListItem[];


================================================
FILE: tsconfig.buildtypes.json
================================================
/* eslint-disable sort-keys */
{
    "extends": "./tsconfig.json",
    "include": ["src/index.tsx"],
    "exclude": ["node_modules", "dist", "example", "src/__tests__"]
}


================================================
FILE: tsconfig.json
================================================
/* eslint-disable sort-keys */
{
    "compilerOptions": {
        "outDir": "dist",
        "module": "esnext",
        "lib": ["dom", "esnext"],
        "moduleResolution": "node",
        "jsx": "react",
        "sourceMap": true,
        "declaration": true,
        "esModuleInterop": true,
        "noImplicitReturns": true,
        "noImplicitThis": true,
        "noImplicitAny": true,
        "strictNullChecks": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "allowSyntheticDefaultImports": true,
        "rootDir": "src",
        "target": "ES5",
        "isolatedModules": true,
        "skipLibCheck": true
    },
    "include": ["src"],
    "exclude": ["node_modules", "dist", "example", "src/__tests__"],
    "types": ["node", "jest", "@testing-library/jest-dom"]
}
Download .txt
gitextract_1p6vrvbp/

├── .babelrc.js
├── .browserslistrc
├── .editorconfig
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .prettierignore
├── .prettierrc.js
├── .travis.yml
├── .vscode/
│   └── settings.json
├── CHANGELOG.md
├── LICENSE
├── README.md
├── example/
│   ├── README.md
│   ├── components/
│   │   ├── GalleryLightbox/
│   │   │   ├── components/
│   │   │   │   ├── GridImage.jsx
│   │   │   │   ├── LightboxArrowButton.jsx
│   │   │   │   ├── LightboxButtonControl.jsx
│   │   │   │   └── LightboxHeader.jsx
│   │   │   └── index.jsx
│   │   └── InlineLightbox/
│   │       └── index.jsx
│   ├── next-env.d.ts
│   ├── next.config.js
│   ├── package.json
│   ├── pages/
│   │   ├── _app.jsx
│   │   ├── _document.jsx
│   │   └── index.tsx
│   └── tsconfig.json
├── jest-setup.ts
├── jest.config.js
├── package.json
├── rollup.config.mjs
├── src/
│   ├── __tests__/
│   │   ├── components/
│   │   │   └── SimpleLightbox.tsx
│   │   └── lightbox.test.tsx
│   ├── components/
│   │   ├── CreatePortal/
│   │   │   └── index.tsx
│   │   ├── ImageStage/
│   │   │   ├── components/
│   │   │   │   ├── Image/
│   │   │   │   │   └── index.tsx
│   │   │   │   ├── ImagePager/
│   │   │   │   │   └── index.tsx
│   │   │   │   └── SSRImagePager/
│   │   │   │       └── SSRImagePager.tsx
│   │   │   ├── index.tsx
│   │   │   └── utils/
│   │   │       ├── getTranslateOffsetsFromScale.ts
│   │   │       ├── imageIsOutOfBounds.ts
│   │   │       ├── index.ts
│   │   │       ├── useDoubleClick.tsx
│   │   │       └── useRefSize.tsx
│   │   ├── PageContainer/
│   │   │   └── index.tsx
│   │   └── index.tsx
│   ├── index.tsx
│   └── types/
│       └── ImagesList.ts
├── tsconfig.buildtypes.json
└── tsconfig.json
Download .txt
SYMBOL INDEX (28 symbols across 14 files)

FILE: example/components/GalleryLightbox/index.jsx
  class BlogImageGallery (line 12) | class BlogImageGallery extends React.Component {
    method constructor (line 31) | constructor() {
    method componentDidMount (line 41) | componentDidMount() {
    method render (line 95) | render() {

FILE: example/pages/_app.jsx
  class MyApp (line 6) | class MyApp extends App {
    method render (line 7) | render() {

FILE: example/pages/_document.jsx
  class MyDocument (line 5) | class MyDocument extends Document {
    method getInitialProps (line 6) | static async getInitialProps(ctx) {

FILE: src/components/CreatePortal/index.tsx
  type ICreatePortal (line 4) | type ICreatePortal = {
  class CreatePortal (line 15) | class CreatePortal extends React.Component<ICreatePortal> {
    method componentDidMount (line 20) | componentDidMount() {
    method componentWillUnmount (line 40) | componentWillUnmount() {
    method render (line 50) | render() {

FILE: src/components/ImageStage/components/Image/index.tsx
  type IImageProps (line 19) | type IImageProps = {

FILE: src/components/ImageStage/components/ImagePager/index.tsx
  type IImagePager (line 8) | type IImagePager = {

FILE: src/components/ImageStage/components/SSRImagePager/SSRImagePager.tsx
  type ISSRImagePagerProps (line 5) | type ISSRImagePagerProps = {

FILE: src/components/ImageStage/index.tsx
  type IImageStageProps (line 8) | type IImageStageProps = {

FILE: src/components/ImageStage/utils/getTranslateOffsetsFromScale.ts
  type IGetTranslateOffsetsFromScale (line 1) | type IGetTranslateOffsetsFromScale = {
  type ITranslateOffsetsReturnType (line 14) | type ITranslateOffsetsReturnType = [translateX: number, translateY: numb...

FILE: src/components/ImageStage/utils/useDoubleClick.tsx
  type IUseDoubleClickProps (line 3) | type IUseDoubleClickProps = {

FILE: src/components/ImageStage/utils/useRefSize.tsx
  type RefSize (line 3) | type RefSize = {
  type Node (line 8) | type Node = HTMLDivElement | null;
  type IUseRefSize (line 10) | type IUseRefSize = [refSize: RefSize, elementRef: (node: any) => void | ...

FILE: src/components/PageContainer/index.tsx
  type IPageContainerProps (line 5) | type IPageContainerProps = {

FILE: src/index.tsx
  type ImagesListType (line 5) | type ImagesListType = ImagesList;
  type ILightboxProps (line 7) | type ILightboxProps = {

FILE: src/types/ImagesList.ts
  type ImagesListItem (line 1) | type ImagesListItem = Omit<
  type ImagesList (line 6) | type ImagesList = ImagesListItem[];
Condensed preview — 49 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (109K chars).
[
  {
    "path": ".babelrc.js",
    "chars": 296,
    "preview": "module.exports = {\n    plugins: [\n        ['@babel/plugin-transform-class-properties'],\n        ['@babel/plugin-transfor"
  },
  {
    "path": ".browserslistrc",
    "chars": 47,
    "preview": "> 0.5%, last 2 versions, Firefox ESR, not dead\n"
  },
  {
    "path": ".editorconfig",
    "chars": 147,
    "preview": "root = true\n\n[*]\ncharset = utf-8\nindent_style = space\nindent_size = 4\nend_of_line = lf\ninsert_final_newline = true\ntrim_"
  },
  {
    "path": ".eslintignore",
    "chars": 408,
    "preview": "# See https://help.github.com/ignore-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\nexample/node_mo"
  },
  {
    "path": ".eslintrc.js",
    "chars": 2094,
    "preview": "/* eslint-disable sort-keys */\n/**\n * Configure ESLint\n *\n * https://eslint.org/docs/user-guide/configuring\n */\nmodule.e"
  },
  {
    "path": ".gitignore",
    "chars": 296,
    "preview": "\n# See https://help.github.com/ignore-files/ for more about ignoring files.\n\n# dependencies\nnode_modules\n\n# builds\ncover"
  },
  {
    "path": ".prettierignore",
    "chars": 182,
    "preview": "node_modules/**\n.next/**\ndist/**\ncoverage/**\nexample/node_modules/**\nexample/.next/**\nyarn.lock\nyarn-error.log\n.editorco"
  },
  {
    "path": ".prettierrc.js",
    "chars": 204,
    "preview": "/**\n * Configure Prettier\n *\n * https://prettier.io/docs/en/configuration.html#basic-configuration\n */\nmodule.exports = "
  },
  {
    "path": ".travis.yml",
    "chars": 355,
    "preview": "language: node_js\nnode_js:\n    - 'lts/*'\ncache:\n    yarn: true\n    directories:\n        - node_modules\ninstall: yarn ins"
  },
  {
    "path": ".vscode/settings.json",
    "chars": 168,
    "preview": "{\n    \"editor.formatOnSave\": true,\n    \"javascript.validate.enable\": false,\n    \"editor.tabSize\": 4,\n    \"editor.detectI"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 6435,
    "preview": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n## Upcoming\n\n-   Add `swipe up` to cl"
  },
  {
    "path": "LICENSE",
    "chars": 1080,
    "preview": "MIT License\n\nCopyright (c) 2019-present Tim Ellenberger\n\nPermission is hereby granted, free of charge, to any person obt"
  },
  {
    "path": "README.md",
    "chars": 7625,
    "preview": "# react-spring-lightbox\n\n[![npm](https://img.shields.io/npm/v/react-spring-lightbox.svg?color=brightgreen&style=popout-s"
  },
  {
    "path": "example/README.md",
    "chars": 239,
    "preview": "# react-spring-lightbox demo app\n\nThis directory contains a Next.js app useful for testing and developing `react-spring-"
  },
  {
    "path": "example/components/GalleryLightbox/components/GridImage.jsx",
    "chars": 2077,
    "preview": "import * as React from 'react';\nimport PropTypes from 'prop-types';\nimport styled from 'styled-components';\n\n/**\n * A si"
  },
  {
    "path": "example/components/GalleryLightbox/components/LightboxArrowButton.jsx",
    "chars": 1545,
    "preview": "/* eslint-disable no-shadow */\nimport * as React from 'react';\nimport PropTypes from 'prop-types';\nimport styled from 's"
  },
  {
    "path": "example/components/GalleryLightbox/components/LightboxButtonControl.jsx",
    "chars": 495,
    "preview": "import styled from 'styled-components';\n\nexport default styled.button`\n    z-index: 10;\n    background: none;\n    border"
  },
  {
    "path": "example/components/GalleryLightbox/components/LightboxHeader.jsx",
    "chars": 2769,
    "preview": "import * as React from 'react';\nimport PropTypes from 'prop-types';\nimport styled from 'styled-components';\nimport { IoI"
  },
  {
    "path": "example/components/GalleryLightbox/index.jsx",
    "chars": 6661,
    "preview": "import { FiHeart, FiPrinter, FiShare } from 'react-icons/fi';\nimport React from 'react';\nimport PropTypes from 'prop-typ"
  },
  {
    "path": "example/components/InlineLightbox/index.jsx",
    "chars": 2586,
    "preview": "import * as React from 'react';\nimport PropTypes from 'prop-types';\nimport styled from 'styled-components';\nimport Light"
  },
  {
    "path": "example/next-env.d.ts",
    "chars": 201,
    "preview": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edite"
  },
  {
    "path": "example/next.config.js",
    "chars": 221,
    "preview": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n    compiler: {\n        styledComponents: true,\n    },\n   "
  },
  {
    "path": "example/package.json",
    "chars": 1042,
    "preview": "{\n    \"name\": \"react-spring-lightbox-example\",\n    \"homepage\": \"https://tim-soft.github.io/react-spring-lightbox\",\n    \""
  },
  {
    "path": "example/pages/_app.jsx",
    "chars": 1452,
    "preview": "import App from 'next/app';\nimport React from 'react';\nimport { createGlobalStyle, ThemeProvider } from 'styled-componen"
  },
  {
    "path": "example/pages/_document.jsx",
    "chars": 929,
    "preview": "import * as React from 'react';\nimport Document from 'next/document';\nimport { ServerStyleSheet } from 'styled-component"
  },
  {
    "path": "example/pages/index.tsx",
    "chars": 7375,
    "preview": "import * as React from 'react';\nimport styled from 'styled-components';\nimport GalleryLightbox from '../components/Galle"
  },
  {
    "path": "example/tsconfig.json",
    "chars": 499,
    "preview": "{\n    \"compilerOptions\": {\n        \"allowJs\": true,\n        \"esModuleInterop\": true,\n        \"incremental\": true,\n      "
  },
  {
    "path": "jest-setup.ts",
    "chars": 36,
    "preview": "import '@testing-library/jest-dom';\n"
  },
  {
    "path": "jest.config.js",
    "chars": 521,
    "preview": "/**\n * Configure Jest as the test runner for @testing-library\n *\n * @see https://jestjs.io/docs/en/configuration\n */\nmod"
  },
  {
    "path": "package.json",
    "chars": 4077,
    "preview": "{\n    \"name\": \"react-spring-lightbox\",\n    \"version\": \"1.8.0\",\n    \"description\": \"A flexible image gallery lightbox wit"
  },
  {
    "path": "rollup.config.mjs",
    "chars": 951,
    "preview": "import commonjs from '@rollup/plugin-commonjs';\nimport filesize from 'rollup-plugin-filesize';\nimport { nodeResolve } fr"
  },
  {
    "path": "src/__tests__/components/SimpleLightbox.tsx",
    "chars": 1275,
    "preview": "import React, { useState } from 'react';\nimport Lightbox, { ImagesListType } from '../../index';\n\nconst images: ImagesLi"
  },
  {
    "path": "src/__tests__/lightbox.test.tsx",
    "chars": 3944,
    "preview": "import React from 'react';\nimport { render, fireEvent, screen } from '@testing-library/react';\nimport Lightbox from './c"
  },
  {
    "path": "src/components/CreatePortal/index.tsx",
    "chars": 1802,
    "preview": "import React from 'react';\nimport ReactDOM from 'react-dom';\n\ntype ICreatePortal = {\n    children: any;\n};\n\n/**\n * Creat"
  },
  {
    "path": "src/components/ImageStage/components/Image/index.tsx",
    "chars": 11890,
    "preview": "import { animated, to, useSpring } from '@react-spring/web';\nimport {\n    getTranslateOffsetsFromScale,\n    imageIsOutOf"
  },
  {
    "path": "src/components/ImageStage/components/ImagePager/index.tsx",
    "chars": 10118,
    "preview": "import { animated, useSprings } from '@react-spring/web';\nimport { useGesture } from 'react-use-gesture';\nimport Image f"
  },
  {
    "path": "src/components/ImageStage/components/SSRImagePager/SSRImagePager.tsx",
    "chars": 1099,
    "preview": "import type { ImagesList } from '../../../../types/ImagesList';\nimport styled, { css } from 'styled-components';\nimport "
  },
  {
    "path": "src/components/ImageStage/index.tsx",
    "chars": 3429,
    "preview": "import ImagePager from './components/ImagePager';\nimport React from 'react';\nimport styled from 'styled-components';\nimp"
  },
  {
    "path": "src/components/ImageStage/utils/getTranslateOffsetsFromScale.ts",
    "chars": 1851,
    "preview": "type IGetTranslateOffsetsFromScale = {\n    /** The current [x,y] translate values of image */\n    currentTranslate: [tra"
  },
  {
    "path": "src/components/ImageStage/utils/imageIsOutOfBounds.ts",
    "chars": 866,
    "preview": "/**\n * Determines if the provided image is within the viewport\n *\n * @returns True if image needs to be resized to fit v"
  },
  {
    "path": "src/components/ImageStage/utils/index.ts",
    "chars": 329,
    "preview": "import getTranslateOffsetsFromScale from './getTranslateOffsetsFromScale';\nimport imageIsOutOfBounds from './imageIsOutO"
  },
  {
    "path": "src/components/ImageStage/utils/useDoubleClick.tsx",
    "chars": 1735,
    "preview": "import React, { useEffect } from 'react';\n\ntype IUseDoubleClickProps = {\n    /** Set to false to disable onDoubleClick/o"
  },
  {
    "path": "src/components/ImageStage/utils/useRefSize.tsx",
    "chars": 1888,
    "preview": "import { useCallback, useEffect, useRef, useState } from 'react';\n\ntype RefSize = {\n    height: number;\n    width: numbe"
  },
  {
    "path": "src/components/PageContainer/index.tsx",
    "chars": 2113,
    "preview": "import React from 'react';\nimport { useTransition, animated, config } from '@react-spring/web';\nimport styled, { AnyStyl"
  },
  {
    "path": "src/components/index.tsx",
    "chars": 180,
    "preview": "import ImageStage from './ImageStage';\nimport PageContainer from './PageContainer';\nimport CreatePortal from './CreatePo"
  },
  {
    "path": "src/index.tsx",
    "chars": 5429,
    "preview": "import React, { useEffect } from 'react';\nimport { ImageStage, PageContainer, CreatePortal } from './components';\nimport"
  },
  {
    "path": "src/types/ImagesList.ts",
    "chars": 241,
    "preview": "export type ImagesListItem = Omit<\n    React.HTMLProps<HTMLImageElement>,\n    'draggable' | 'onClick' | 'onDragStart' | "
  },
  {
    "path": "tsconfig.buildtypes.json",
    "chars": 171,
    "preview": "/* eslint-disable sort-keys */\n{\n    \"extends\": \"./tsconfig.json\",\n    \"include\": [\"src/index.tsx\"],\n    \"exclude\": [\"no"
  },
  {
    "path": "tsconfig.json",
    "chars": 815,
    "preview": "/* eslint-disable sort-keys */\n{\n    \"compilerOptions\": {\n        \"outDir\": \"dist\",\n        \"module\": \"esnext\",\n        "
  }
]

About this extraction

This page contains the full source code of the tim-soft/react-spring-lightbox GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 49 files (99.8 KB), approximately 24.7k tokens, and a symbol index with 28 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!