master c8aad4613aba cached
23 files
54.5 KB
16.0k tokens
4 symbols
1 requests
Download .txt
Repository: sylvaindubus/react-prismazoom
Branch: master
Commit: c8aad4613aba
Files: 23
Total size: 54.5 KB

Directory structure:
gitextract_nqmvr7bf/

├── .eslintrc.json
├── .gitignore
├── .npmignore
├── .npmrc
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── demo/
│   ├── index.html
│   ├── package.json
│   ├── src/
│   │   ├── App/
│   │   │   ├── App.css
│   │   │   └── index.tsx
│   │   ├── index.d.ts
│   │   └── index.tsx
│   ├── tsconfig.json
│   └── types.d.ts
├── package.json
├── src/
│   ├── index.test.js
│   ├── index.tsx
│   └── types.ts
└── tsconfig.json

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

================================================
FILE: .eslintrc.json
================================================
{
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:react-hooks/recommended",
    "prettier"
  ],
  "parser": "@typescript-eslint/parser",
  "rules": {
    "array-bracket-spacing": 0,
    "no-trailing-spaces": 1,
    "no-tabs": 1
  },
  "env": {
    "browser": true,
    "es2021": true
  },
  "overrides": [],
  "parserOptions": {
    "ecmaVersion": "latest",
    "sourceType": "module"
  },
  "plugins": ["react-hooks", "@typescript-eslint", "prettier"]
}


================================================
FILE: .gitignore
================================================
node_modules
dist
.DS_Store
.cache
.parcel-cache


================================================
FILE: .npmignore
================================================
src
demo
tests
.babelrc
.eslintrc.json
.gitignore
.nvmrc
.prettierignore
.prettierrc
tsconfig.json

================================================
FILE: .npmrc
================================================
tag-version-prefix=""

================================================
FILE: .nvmrc
================================================
18.1.0

================================================
FILE: .prettierignore
================================================
*.md

================================================
FILE: .prettierrc
================================================
{
  "singleQuote": true,
  "printWidth": 120,
  "semi": false
}

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

## [3.3.5] - 2023-08-12
- Fix hebavior of `allowPan` and `allowZoom` props (thanks [Robert Brownstein](https://github.com/rbrownstein-bd))

## [3.3.4] - 2023-07-02
- Expose setZoom and setPos methods

## [3.3.3] - 2023-03-29
- Add the move method to the ref type object

## [3.3.2] - 2023-03-25
- Add access to the move method from the ref object

## [3.3.1] - 2023-03-02
- Build fix

## [3.3.0] - 2023-03-01
- Added optional ignoredMouseButtons prop (thanks apomelitos)

## [3.2.1] - 2023-02-24
- Fix double-click zoom target

## [3.2.0] - 2023-02-24
- Refactor codebase to Typescript and functional components (thanks erickriva)
- Switch to parcel as build tool (thanks erickriva)
- Improve performances
- Added custom prop to disable mouse wheel (thanks JenniferGoijman)
- Fixed an issue when using SSR (thanks gregorjan)
- Bump some dependencies

## [3.1.1] - 2022-11-20
- Bump some dependencies

## [3.1.0] - 2022-09-25
- Added prop to allow parent movement (thanks SaadTaimoor-TFD)
- Bump again dev dependencies

## [3.0.4] - 2022-09-24
- Bump dev dependencies to prevent vulnerabilities

## [3.0.3] - 2022-05-08
- Fix panning on React 18
- Improve splitting between lib and demo app
- Temporary disable unit tests

## [3.0.2] - 2022-04-23
- Update dependencies
- Include React 18 as peer dependencies

## [3.0.1] - 2022-04-17
- Fix zooming on mobile when pan is disabled

## [3.0.0] - 2022-01-24
- Replace locked prop with allowZoom and allowPan to handle zooming and panning events separately (thanks joshuacerdenia)

## [2.2.0] - 2022-01-11
- Add a prop `allowTouchEvents` to allow event propagation (thanks fkrauthan)

## [2.1.0] - 2021-12-26
- Add a prop to lock the component

## [2.0.3] - 2021-07-06
- Prevent error when component is unmounted but still moving
- Fix double-tap bug on Safari iOS

## [2.0.2] - 2021-06-23
- Includes React 17 as peer dependencies

## [2.0.1] - 2021-04-22
- Use wrapper boundaries instead of specified props

## [2.0.0] - 2021-02-26
- Update all dependencies
- Rework on the example page
- Improve mousewheel zoom
- Fix and improve unit tests
- Fix chrome warning during zoom
- Change some eslint and babel rules
- Improve reference handling

## [1.1.5] - 2021-02-26
- Added onPanChange callback method (thanks Frozen-byte)

## [1.1.4] - 2020-12-25
- Fix calculating absolute position (thanks sbekaert)

## [1.1.3] - 2019-10-09
- Update some dependencies, clean code

## [1.1.2] - 2018-12-11
- Remove preventDefault from touchStop event

## [1.1.1] - 2018-09-20
- Fix another bug on mouse wheel zoom

## [1.1.0] - 2018-09-19
- Add movement deceleration on mouse up and touch end events
- Greatly improve example project
- Fix blur effect on mouse wheel zoom

## [1.0.3] - 2018-08-10
- Add unit tests using [Intern](https://theintern.io/)
- Improve performances with translate3d and will-change CSS properties
- Fix a bug on panning when the element is not centered

## [1.0.2] - 2018-08-08
- Fix on README documentation
- Lower React dependencies (v16.0)

## [1.0.1] - 2018-08-08
- Improve README documentation
- Add code documentation
- Add NPM and GitLab CI config files
- Add License
- Add animation duration in props
- Add zoom in and out buttons in example project


================================================
FILE: LICENSE.md
================================================
Copyright © 2004-2013 by Internet Systems Consortium, Inc. (“ISC”)
Copyright © 1995-2003 by Internet Software Consortium

Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.

THE SOFTWARE IS PROVIDED “AS IS” AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

================================================
FILE: README.md
================================================
# react-prismazoom

## About

A pan and zoom component for React, using CSS transformations.

Depends only upon prop-types, react and react-dom modules.  
Works on both desktop and mobile.

Online demo [here!](https://sylvaindubus.github.io/react-prismazoom/)

### Zoom features :mag_right:
* Zoom with the mouse wheel or a two-finger pinch
* Zoom using double-click or double-tap
* Zoom on the selected area and center

### Pan features :point_up_2:
* Pan with the mouse pointer or with one finger when zoomed-in
* Intuitive panning depending on available space when zoomed-in
* Adjusts cursor style to indicate in which direction the element can be moved

## Contribution

If you want to contribute, feel free to send a merge request or open a discussion. Currently, I just have time to maintain the package, but not enough to make big changes or add important features.

All contributions would be quite appreciated! 😉

Among changes I would like to apply:
~~- Migrate to TypeScript~~
~~- Transform to a functional component (that could help split the code)~~
- Make motion logic less dependent to React
- Replace Enzyme with another testing library

## Breaking changes on v3

* The `locked` prop has been replaced by `allowZoom` and `allowPan` to handle zooming and panning events separately

## Breaking changes on v2

* The package now requires React v16.3 or higher (to use react references)
* The zoom feature through gestures or the mouse wheel got some improvements to react better with all devices. You may need to adjust the `scrollVelocity` property passed to the component to keep the same effect.


## Installation

### Install the component

```bash
$ npm i -D react-prismazoom
```

### Install the demo

This project includes a full-featured application demo.

First clone the project.

Go to the subfolder:
```bash
$ cd demo
```

Then, install it:

```bash
$ npm ci
```

Run the Webpack Dev Server:

```bash
$ npm start
```

### Run unit tests

⚠️ There are no unit tests anymore since the previously used library is deprecated and doesn't support React 18. The current test suite needs to be adapted using a different library.

## Usage

### Implementation

```jsx
import PrismaZoom from 'react-prismazoom'

<PrismaZoom>
  <img src="my-image.png" />
  <p>A text that can be zoomed and dragged</p>
</PrismaZoom>
```

### Props

| Name | Type | Default | Description |
| --- | --- | --- |  --- |
| className | string | None | Class name to apply on the zoom wrapper. |
| style | object | None | Style to apply on the zoom wrapper. Note that *transform*, *transition*, *cursor*, *touch-action* and *will-change* cannot be overridden. Example: `style={{backgroundColor: 'red'}}`. |
| minZoom | number | 1 | Minimum zoom ratio. |
| maxZoom | number | 5 | Maximum zoom ratio. |
| scrollVelocity | number | 0.1 | Zoom increment or decrement on each scroll wheel detection. |
| onZoomChange | function | null | Function called each time the zoom value changes. |
| onPanChange | function | null | Function called each time the posX or posY value changes (aka images was panned). |
| animDuration | number | 0.25 | Animation duration (in seconds). |
| doubleTouchMaxDelay | number | 300 | Max delay between two taps to consider a double tap (in milliseconds). |
| decelerationDuration | number | 750 | Decelerating movement duration after a mouse up or a touch end event (in milliseconds). |
| allowZoom | boolean | true | Enable or disable zooming in place.
| allowPan | boolean | true | Enable or disable panning in place.
| allowTouchEvents | boolean | false | Enables touch event propagation. |
| allowParentPanning | boolean | false | When enabled, allows the parent element/page to pan with single-finger touch events as long as zoom = 1. |
| allowWheel | boolean | true | Enable or disable mouse wheel and touchpad zooming in place |
| ignoredMouseButtons | number[] | [] | Optional array of ignored mouse buttons allows to prevent panning for specific mouse buttons. By default all mouse buttons are enabled. [MDN](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button#value) |

**Note:** all props are optional.

### Public Methods

These functions can be called from parent components.

**zoomIn (value)**
*Increments the zoom with the given value.*
Param {value: Number} : Zoom value

**zoomOut (value)**
*Decrements the zoom with the given value.*
Param {value: Number} : Zoom value

**zoomToZone (relX, relY, relWidth, relHeight)**
*Zoom in on the specified zone with the given relative coordinates and dimensions.*
Param {relX: Number}: Relative X position of the zone left-top corner in pixels
Param {relY: Number}: Relative Y position of the zone left-top corner in pixels
Param {relWidth: Number}: Zone width in pixels
Param {relHeight: Number}: Zone height in pixels

**reset ()**
*Resets the component to its initial state.*

**getZoom ()**
*Returns the current zoom value.*
Return {Number} : Zone value

## License

React PrismaZoom is licensed under the ISC license. See the LICENSE.md file for more details.


================================================
FILE: demo/index.html
================================================
<!DOCTYPE html>
<html>
  <head>
    <title>react-prismazoom</title>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/png" sizes="32x32" href="./src/static/favicon-32x32.png" />
    <link rel="icon" type="image/png" sizes="16x16" href="./src/static/favicon-16x16.png" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
    <link rel="preconnect" href="https://fonts.gstatic.com" />
    <meta name="theme-color" content="#000" />
    <link href="https://fonts.googleapis.com/css2?family=Jost:wght@300;400&display=swap" rel="stylesheet" />
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <script src="./src/index.tsx" type="module"></script>
  </body>
</html>


================================================
FILE: demo/package.json
================================================
{
  "name": "demo",
  "scripts": {
    "start": "parcel index.html --no-cache",
    "build": "parcel build index.html --public-url ./",
    "publish": "npm run build && gh-pages -d dist",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "devDependencies": {
    "gh-pages": "^3.2.3",
    "parcel": "^2.8.2",
    "parcel-bundler": "^1.12.5",
    "react": "^17.0.2",
    "react-dom": "^17.0.2"
  },
  "alias": {
    "react": "./node_modules/react"
  }
}


================================================
FILE: demo/src/App/App.css
================================================
html {
  font-size: 80%;
}

@media (min-width: 480px) {
  html {
    font-size: 100%;
  }
}

body {
  min-width: 320px;
  margin: 0;
  background-color: #111;
  font-family: 'Jost', sans-serif;
}

h1,
h2 {
  margin: 0;
  font-weight: normal;
}

.App {
  text-align: center;
}

.App-logo {
  animation: App-logo-spin infinite 20s linear;
  height: 100%;
}

.App-header {
  height: 80px;
  padding: 20px;
  color: white;
  background-color: #000;
}

.App-header h1 {
  display: inline-block;
  font-size: 3rem;
  line-height: 3rem;
  background-image: linear-gradient(120deg, #155799 50%, #991557);
  background-clip: text;
  background-size: 200% 100%;
  background-position: 100%;
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
}

.App-header h2 {
  margin-top: 0.5em;
  font-size: 1rem;
  color: #ddd;
}

.App-footer {
  position: absolute;
  bottom: 0px;
  text-align: center;
  width: 100%;
}

.App-indicator {
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  padding: 6px 15px;
  background-color: #111;
  color: #fff;
  border-radius: 4px 4px 0 0;
}

.App-button {
  width: 32px;
  height: 32px;
  padding: 0;
  text-align: center;
  border: none;
  border-radius: 50%;
  outline: none;
  background: none;
  color: #fff;
  font-size: 0.75rem;
  cursor: pointer;
  vertical-align: middle;
}

.App-buttonIcon {
  display: block;
  fill: currentColor;
  height: 100%;
}

.App-wrapper {
  display: flex;
  height: calc(100vh - 180px);
  align-items: center;
  justify-content: center;
  overflow: hidden;
  margin: 15px;
  position: relative;
}

.App-zoom {
  display: block;
  width: 100%;
  height: 100%;
}

.App-image {
  width: 100%;
  height: 100%;
  background-size: cover;
  background-repeat: no-repeat;
  background-position: center center;
}

.App-card {
  display: none;
  position: absolute;
  bottom: 30px;
  right: 30px;
  width: 360px;
  padding: 1.5em;
  font-weight: 300;
  text-align: left;
  background-color: rgba(0, 0, 0, 0.7);
  color: #fff;
}

.App-cardHeader {
  margin-bottom: 1em;
}

.App-card h3 {
  margin: 0;
  font-weight: 400;
  font-size: 1.5rem;
}

.App-card p {
  margin: 0 0 1em 0;
}

.App-card a,
.App-card a:visited {
  color: #61dafb;
}

.App-zoomLabel {
  display: inline-block;
  width: 60px;
  vertical-align: middle;
}

@media (min-width: 768px) {
  .App-wrapper {
    margin: 30px;
  }
  .App-card {
    display: block;
  }
}


================================================
FILE: demo/src/App/index.tsx
================================================
import React, { ComponentRef, MouseEvent, useCallback, useRef, useState } from 'react'

import PrismaZoom from '../../../src'
import backgroundOne from './images/radeau-de-la-meduse.jpg'
import backgroundTwo from './images/eruption-du-vesuve.jpg'
import './App.css'

const App = () => {
  const prismaZoom = useRef<ComponentRef<typeof PrismaZoom>>(null)
  const zoomCounterRef = useRef<HTMLSpanElement>(null)

  const [allowZoom, setAllowZoom] = useState(true)
  const [allowPan, setAllowPan] = useState(true)

  const onZoomChange = useCallback((zoom: number) => {
    if (!zoomCounterRef.current) return
    zoomCounterRef.current.innerText = `${Math.round(zoom * 100)}%`
  }, [])

  const onClickOnZoomOut = () => {
    prismaZoom.current?.zoomOut(1)
  }

  const onClickOnZoomIn = () => {
    prismaZoom.current?.zoomIn(1)
  }

  const onClickOnLock = () => {
    setAllowPan((allowPan) => !allowPan)
    setAllowZoom((allowZoom) => !allowZoom)
  }

  const onDoubleClickOnCard = (event: MouseEvent) => {
    event.preventDefault()
    event.stopPropagation()

    if (!prismaZoom.current || !event.currentTarget?.parentNode) return

    const zoneRect = event.currentTarget.getBoundingClientRect()
    const layoutRect = (event.currentTarget.parentNode as Element).getBoundingClientRect()

    const zoom = prismaZoom.current.getZoom()

    if (zoom > 1) {
      prismaZoom.current?.reset()
      return
    }

    const [relX, relY] = [(zoneRect.left - layoutRect.left) / zoom, (zoneRect.top - layoutRect.top) / zoom]
    const [relWidth, relHeight] = [zoneRect.width / zoom, zoneRect.height / zoom]
    prismaZoom.current?.zoomToZone(relX, relY, relWidth, relHeight)
  }

  return (
    <div className="App">
      <header className="App-header">
        <h1>react-prismazoom</h1>
        <h2>A pan and zoom component for React, using CSS transformations.</h2>
      </header>

      <section className="App-wrapper">
        <PrismaZoom className="App-zoom" onZoomChange={onZoomChange} maxZoom={8} minZoom={1} ref={prismaZoom}>
          <div className="App-image" style={{ backgroundImage: `url(${backgroundOne})` }}></div>
          <article className="App-card" onDoubleClick={onDoubleClickOnCard}>
            <header className="App-cardHeader">
              <h3>The Raft of the Medusa</h3>
              <span>Théodore Géricault</span>
            </header>
            <p>
              The Raft of the Medusa (French: Le Radeau de la Méduse) – originally titled Scène de Naufrage (Shipwreck
              Scene) – is an oil painting of 1818–19 by the French Romantic painter and lithographer Théodore Géricault
              (1791–1824). Completed when the artist was 27, the work has become an icon of French Romanticism.
            </p>
            <p>
              <a href="https://en.wikipedia.org/wiki/The_Raft_of_the_Medusa" target="_blank" rel="noreferrer">
                Go to Wikipedia.
              </a>
            </p>
            <footer>
              <strong>Tip: </strong>double-click on this card to zoom. 😉
            </footer>
          </article>
        </PrismaZoom>

        <footer className="App-footer">
          <div className="App-indicator">
            <button className="App-button" onClick={onClickOnZoomOut}>
              <svg className="App-buttonIcon" viewBox="0 0 24 24">
                <path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8zm4-9H8a1 1 0 0 0 0 2h8a1 1 0 0 0 0-2z" />
              </svg>
            </button>
            <span className="App-zoomLabel" ref={zoomCounterRef}>
              100%
            </span>
            <button className="App-button" onClick={onClickOnZoomIn}>
              <svg className="App-buttonIcon" viewBox="0 0 24 24">
                <path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8zm4-9h-3V8a1 1 0 0 0-2 0v3H8a1 1 0 0 0 0 2h3v3a1 1 0 0 0 2 0v-3h3a1 1 0 0 0 0-2z" />
              </svg>
            </button>
          </div>
        </footer>
      </section>

      <section className="App-wrapper">
        <PrismaZoom className="App-zoom" allowZoom={allowZoom} allowPan={allowPan} maxZoom={8}>
          <div className="App-image" style={{ backgroundImage: `url(${backgroundTwo})` }}></div>
          <article className="App-card">
            <header className="App-cardHeader">
              <h3>Vesuvius in Eruption</h3>
              <span>Joseph Mallord William Turner</span>
            </header>
            <p>
              The eighteenth-century fascination with volcanoes, and Vesuvius in particular, deepened in the nineteenth
              century, fuelled by the eruptions of Vesuvius in 1794, 1807, 1819, and 1822.
            </p>
          </article>
        </PrismaZoom>

        <footer className="App-footer">
          <div className="App-indicator">
            <button className="App-button" onClick={onClickOnLock}>
              <svg className="App-buttonIcon" viewBox="0 0 24 24">
                {!allowPan && !allowZoom ? (
                  <path d="M12 13a1.49 1.49 0 0 0-1 2.61V17a1 1 0 0 0 2 0v-1.39A1.49 1.49 0 0 0 12 13zm5-4V7A5 5 0 0 0 7 7v2a3 3 0 0 0-3 3v7a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-7a3 3 0 0 0-3-3zM9 7a3 3 0 0 1 6 0v2H9zm9 12a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1z" />
                ) : (
                  <path d="M12 13a1.49 1.49 0 0 0-1 2.61V17a1 1 0 0 0 2 0v-1.39A1.49 1.49 0 0 0 12 13zm5-4H9V7a3 3 0 0 1 5.12-2.13 3.08 3.08 0 0 1 .78 1.38 1 1 0 1 0 1.94-.5 5.09 5.09 0 0 0-1.31-2.29A5 5 0 0 0 7 7v2a3 3 0 0 0-3 3v7a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-7a3 3 0 0 0-3-3zm1 10a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1z" />
                )}
              </svg>
            </button>
          </div>
        </footer>
      </section>
    </div>
  )
}

export default App


================================================
FILE: demo/src/index.d.ts
================================================
declare module '*.jpg'


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

import App from './App'

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
)


================================================
FILE: demo/tsconfig.json
================================================
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "removeComments": true,
    "sourceMap": true,
    "types": ["node"]
  }
}


================================================
FILE: demo/types.d.ts
================================================
declare module '*.jpg'


================================================
FILE: package.json
================================================
{
  "name": "react-prismazoom",
  "version": "3.3.5",
  "description": "A pan and zoom component for React, using CSS transformations.",
  "author": "Sylvain Dubus <svn.dbs@gmail.com>",
  "contributors": [
    "Erick Estevão Riva Pramio <erickriva@hotmail.com.br>"
  ],
  "license": "ISC",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/sylvaindubus/react-prismazoom"
  },
  "keywords": [
    "react",
    "react-component",
    "zoom",
    "pan",
    "drag",
    "pinch-zoom",
    "css3"
  ],
  "main": "./dist/cjs/index.js",
  "module": "./dist/esm/index.js",
  "types": "./dist/esm/index.d.ts",
  "scripts": {
    "build": "npm run build:esm && npm run build:cjs",
    "build:esm": "tsc",
    "build:cjs": "tsc --module commonjs --outDir dist/cjs",
    "build-watch": "npm run build:esm -- -w && npm run build:cjs -- -w",
    "test": "echo \"Error: no test specified\" && exit 1",
    "lint": "eslint ./src/** --fix"
  },
  "peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
  },
  "devDependencies": {
    "@types/react": "^18.0.26",
    "@typescript-eslint/eslint-plugin": "^5.46.0",
    "@typescript-eslint/parser": "^5.46.0",
    "eslint": "^8.14.0",
    "eslint-config-prettier": "^8.5.0",
    "eslint-plugin-prettier": "^4.0.0",
    "eslint-plugin-react": "^7.31.11",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-webpack-plugin": "^3.1.1",
    "parcel": "^2.8.1",
    "parcel-bundler": "^1.12.5",
    "prettier": "^2.6.2",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "typescript": "^4.9.4"
  },
  "optionalDependencies": {
    "fsevents": "*"
  }
}


================================================
FILE: src/index.test.js
================================================
// TODO: Rework all tests using a different library

// import React from 'react'
// import { mount, configure } from 'enzyme'
// import { JSDOM } from 'jsdom'
// import Adapter from '@wojtekmaj/enzyme-adapter-react-17'

// import PrismaZoom from '../../src'

// configure({ adapter: new Adapter() })

// const { describe, it, beforeEach } = intern.getPlugin('interface.bdd')
// const { expect } = intern.getPlugin('chai')

// const documentHTML = '<!doctype html><html><head></head><body><div></div></body></html>'
// const jsdom = new JSDOM(documentHTML, { pretendToBeVisual: true })
// global.window = jsdom.window
// global.window.matchMedia = () => ({
//   matches: true,
// })
// global.document = jsdom.window.document
// global.navigator = { userAgent: 'node.js' }

// const [containerWidth, containerHeight] = [1440, 800]

// const mockGetBoudingClientRect = (falseData) => {
//   window.HTMLElement.prototype.getBoundingClientRect = function () {
//     if (this.className === 'prismaZoom') {
//       // Return data for the PrismaZoom element
//       const data = {
//         width: 640,
//         height: 360,
//         top: 0,
//         left: 0,
//         right: 640,
//         bottom: 360,
//         ...falseData,
//       }
//       return data
//     } else {
//       // Return data for the parent element
//       return {
//         width: containerWidth,
//         height: containerHeight,
//         top: 0,
//         left: 0,
//         bottom: containerWidth,
//         right: containerHeight,
//       }
//     }
//   }
// }

// describe('components', () => {
//   describe('PrismaZoom', () => {
//     const props = {
//       minZoom: 1,
//       maxZoom: 5,
//     }
//     const component = mount(
//       <PrismaZoom className="prismaZoom" {...props}>
//         <div></div>
//       </PrismaZoom>
//     )
//     const instance = component.instance()
//     const defaultState = instance.state

//     beforeEach(() => {
//       // Re-initialize default state
//       component.setState(defaultState)

//       // Override clientWidth and clientHeight getters
//       Object.defineProperty(document.body, 'clientWidth', {
//         get: () => containerWidth,
//         configurable: true,
//       })
//       Object.defineProperty(document.body, 'clientHeight', {
//         get: () => containerHeight,
//         configurable: true,
//       })
//     })

//     it('renders correctly', () => {
//       expect(component.prop('className')).to.equal('prismaZoom')
//       expect(component.state('zoom')).to.equal(1)
//     })

//     describe('getNewPosition', () => {
//       it('returns initial position if zoom is equal to 1', () => {
//         expect(instance.getNewPosition(5, 5, 1)).to.eql([0, 0])
//       })

//       it('returns new position when zoom-in', () => {
//         mockGetBoudingClientRect()
//         expect(instance.getNewPosition(20, 20, 1.5)).to.eql([150, 80])
//       })

//       it('returns new position when zoom-out', () => {
//         component.setState({ zoom: 1.5, posX: 150, posY: 80 })
//         expect(instance.getNewPosition(20, 20, 1.25)).to.eql([75, 40])
//       })
//     })

//     describe('getLimitedShift', () => {
//       it('returns 0 if element cannot be panned', () => {
//         expect(instance.getLimitedShift(10, 0, 1440, 0, 3195)).to.eql(0)
//         expect(instance.getLimitedShift(-10, 0, 1440, 0, -1760)).to.eql(0)
//       })
//       it('returns limited shift if the shift is too high', () => {
//         expect(instance.getLimitedShift(10, 0, 1440, -5, 3195)).to.eql(5)
//         expect(instance.getLimitedShift(-10, 0, 1440, -1755, 1445)).to.eql(-5)
//       })
//       it('returns current shift if the move is far enough from borders', () => {
//         expect(instance.getLimitedShift(10, 0, 1440, -1590, 1600)).to.eql(10)
//         expect(instance.getLimitedShift(-10, 0, 1440, -1590, 1600)).to.eql(-10)
//       })
//     })

//     describe('getCursor', () => {
//       it('returns adapted cursor if element cannot be panned', () => {
//         expect(instance.getCursor()).to.eql('auto')
//       })
//       it('returns adapted cursor if element can only be panned horizontally', () => {
//         expect(instance.getCursor(true, false)).to.eql('ew-resize')
//       })
//       it('returns adapted cursor if element can only be panned vertically', () => {
//         expect(instance.getCursor(false, true)).to.eql('ns-resize')
//       })
//       it('returns adapted cursor if element can be panned on both directions', () => {
//         expect(instance.getCursor(true, true)).to.eql('move')
//       })
//     })

//     describe('fullZoomInOnPosition', () => {
//       it('zoom-in at the maximum value', () => {
//         instance.fullZoomInOnPosition(5, 5)
//         expect(instance.state).to.eql({
//           zoom: 5,
//           posX: 1260,
//           posY: 700,
//           cursor: 'auto',
//           transitionDuration: 0.25,
//         })
//       })
//     })

//     describe('move', () => {
//       it('does not changes position if panning is impossible', () => {
//         instance.move(20, 20, 0)
//         expect(instance.state.zoom).to.eql(1)
//         expect(instance.state.posX).to.eql(0)
//         expect(instance.state.posY).to.eql(0)
//         expect(instance.state.cursor).to.eql('auto')
//       })

//       it('changes position toward bottom-right corner', () => {
//         mockGetBoudingClientRect({ width: 1920, height: 1920, bottom: 1920, right: 1920 })
//         component.setState({ zoom: 2, posX: 640, posY: 640 })
//         instance.move(-20, -20, 0)
//         expect(instance.state.posX).to.eql(620)
//         expect(instance.state.posY).to.eql(620)
//         expect(instance.state.cursor).to.eql('move')
//       })

//       it('changes position toward left-top corner with a limited shift', () => {
//         mockGetBoudingClientRect({
//           width: 1920,
//           height: 1080,
//           left: -10,
//           top: -10,
//           bottom: 1070,
//           right: 1910,
//         })
//         component.setState({ zoom: 3, posX: 630, posY: 350 })
//         instance.move(20, 20)
//         expect(instance.state.posX).to.eql(640)
//         expect(instance.state.posY).to.eql(360)
//         expect(instance.state.cursor).to.eql('move')
//       })

//       it('changes position on X axis only', () => {
//         mockGetBoudingClientRect({
//           width: containerWidth * 2,
//           height: 600,
//           left: 0,
//           top: 0,
//           bottom: 600,
//           right: containerWidth * 2,
//         })
//         component.setState({ zoom: 2, posX: 640, posY: 360 })
//         instance.move(-20, -20)
//         expect(instance.state.posX).to.eql(620)
//         expect(instance.state.posY).to.eql(360)
//         expect(instance.state.cursor).to.eql('ew-resize')
//       })

//       it('changes position on Y axis only', () => {
//         mockGetBoudingClientRect({
//           width: 600,
//           height: containerHeight * 2,
//           left: 0,
//           top: 0,
//           bottom: containerHeight * 2,
//           right: 600,
//         })
//         component.setState({ zoom: 2, posX: 640, posY: 350 })
//         instance.move(-20, -20)
//         expect(instance.state.posX).to.eql(640)
//         expect(instance.state.posY).to.eql(330)
//         expect(instance.state.cursor).to.eql('ns-resize')
//       })
//     })

//     describe('zoomIn', () => {
//       it('increments the zoom value', () => {
//         instance.zoomIn(3)
//         expect(component.state('zoom')).to.equal(4)
//         instance.zoomIn(3)
//         expect(component.state('zoom')).to.equal(props.maxZoom)
//       })
//     })

//     describe('zoomOut', () => {
//       it('decrements the zoom value', () => {
//         component.setState({ zoom: props.maxZoom })
//         instance.zoomOut(3)
//         expect(component.state('zoom')).to.equal(2)
//         instance.zoomOut(3)
//         expect(component.state('zoom')).to.equal(props.minZoom)
//       })
//     })

//     describe('zoomToZone', () => {
//       it('zoom-in on the specified zone', () => {
//         mockGetBoudingClientRect()
//         component.setState({ zoom: 1, posX: 640, posY: 360 })
//         instance.zoomToZone(400, 10, 230, 340)
//         expect(instance.state).to.eql({
//           zoom: 2.3529411764705883,
//           posX: -458.8235294117647,
//           posY: 0,
//           cursor: 'auto',
//           transitionDuration: 0.25,
//         })
//       })
//     })

//     describe('reset', () => {
//       it('resets the state', () => {
//         instance.reset()
//         expect(instance.state).to.eql(defaultState)
//       })
//     })

//     describe('getZoom', () => {
//       it('returns the current zoom value', () => {
//         component.setState({ zoom: 2 })
//         expect(instance.getZoom()).to.eql(2)
//       })
//     })
//   })
// })


================================================
FILE: src/index.tsx
================================================
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import type { Props, Ref, PositionType, CursorType } from './types'

// Transform translateX ans translateY value property
const defaultPos: PositionType = [0, 0]

// Cursor style property
const defaultCursor = 'auto'

const PrismaZoom = forwardRef<Ref, Props>((props, forwardedRef) => {
  const {
    children,
    onPanChange,
    onZoomChange,
    minZoom = 1,
    initialZoom = 1,
    maxZoom = 5,
    scrollVelocity = 0.2,
    animDuration = 0.25,
    doubleTouchMaxDelay = 300,
    decelerationDuration = 750,
    allowZoom = true,
    allowPan = true,
    allowTouchEvents = false,
    allowParentPanning = false,
    allowWheel = true,
    ignoredMouseButtons = [],
    ...divProps
  } = props

  // Reference to the main element
  const ref = useRef<HTMLDivElement>(null)
  // Last request animation frame identifier
  const lastRequestAnimationIdRef = useRef<number | null>()
  // Last touch time in milliseconds
  const lastTouchTimeRef = useRef<number>()
  // Last double tap time (used to limit multiple double tap) in milliseconds
  const lastDoubleTapTimeRef = useRef<number>()
  // Last shifted position
  const lastShiftRef = useRef<PositionType | null>()
  // Last calculated distance between two fingers in pixels
  const lastTouchDistanceRef = useRef<number | null>()
  // Last cursor position
  const lastCursorRef = useRef<PositionType | null>()
  // Last touch position
  const lastTouchRef = useRef<PositionType | null>()
  // Current zoom level
  const zoomRef = useRef(initialZoom)
  // Current position
  const posRef = useRef(defaultPos)
  // Current transition duration
  const transitionRef = useRef(animDuration)

  const [cursor, setCursor] = useState<CursorType>(defaultCursor)

  const update = useCallback(() => {
    if (!ref.current) return
    ref.current.style.transition = `transform ease-out ${transitionRef.current}s`
    ref.current.style.transform = `translate3d(${posRef.current[0]}px, ${posRef.current[1]}px, 0) scale(${zoomRef.current})`
  }, []);

  const setZoom = useCallback((zoom: number) => {
    zoomRef.current = zoom
    update()
    if (onZoomChange) {
      onZoomChange(zoom)
    }
  }, [update, onZoomChange]);

  const setPos = useCallback((pos: PositionType) => {
    posRef.current = pos
    update()
    if (onPanChange) {
      onPanChange({ posX: pos[0], posY: pos[1] })
    }
  }, [update, onPanChange]);

  const setTransitionDuration = useCallback((duration: number) => {
    transitionRef.current = duration
    update()
  }, [update]);

  /**
   * Returns the current zoom value.
   * @return {Number} Zoom value
   */
  const getZoom = useCallback(() => zoomRef.current, []);

  /**
   * Increments the zoom with the given value.
   * @param  {Number} value Zoom value
   */
  const zoomIn = useCallback((value: number) => {
    let newPosX = posRef.current[0]
    let newPosY = posRef.current[1]

    const prevZoom = zoomRef.current

    const newZoom = prevZoom + value < maxZoom ? prevZoom + value : maxZoom

    if (newZoom !== prevZoom) {
      newPosX = (newPosX * (newZoom - 1)) / (prevZoom > 1 ? prevZoom - 1 : prevZoom)
      newPosY = (newPosY * (newZoom - 1)) / (prevZoom > 1 ? prevZoom - 1 : prevZoom)
    }

    setZoom(newZoom)
    setPos([newPosX, newPosY])
    setTransitionDuration(animDuration)
  }, [setZoom, setPos, setTransitionDuration, animDuration, maxZoom]);

  /**
   * Decrements the zoom with the given value.
   * @param  {Number} value Zoom value
   */
  const zoomOut = useCallback((value: number) => {
    let newPosX = posRef.current[0]
    let newPosY = posRef.current[1]

    const prevZoom = zoomRef.current

    const newZoom = prevZoom - value > minZoom ? prevZoom - value : minZoom

    if (newZoom !== prevZoom) {
      newPosX = (newPosX * (newZoom - 1)) / (prevZoom - 1)
      newPosY = (newPosY * (newZoom - 1)) / (prevZoom - 1)
    }

    setZoom(newZoom)
    setPos([newPosX, newPosY])
    setTransitionDuration(animDuration)
  }, [setZoom, setPos, setTransitionDuration, animDuration, minZoom])

  /**
   * Zoom-in on the specified zone with the given relative coordinates and dimensions.
   * @param  {Number} relX      Relative X position of the zone left-top corner in pixels
   * @param  {Number} relY      Relative Y position of the zone left-top corner in pixels
   * @param  {Number} relWidth  Zone width in pixels
   * @param  {Number} relHeight Zone height in pixels
   */
  const zoomToZone = useCallback((relX: number, relY: number, relWidth: number, relHeight: number) => {
    if (!ref.current) return

    let newPosX = posRef.current[0]
    let newPosY = posRef.current[1]
    const parentRect = (ref.current?.parentNode as HTMLElement).getBoundingClientRect()

    const prevZoom = zoomRef.current

    // Calculate zoom factor to scale the zone
    const optimalZoomX = parentRect.width / relWidth
    const optimalZoomY = parentRect.height / relHeight
    const newZoom = Math.min(optimalZoomX, optimalZoomY, maxZoom)

    // Calculate new position to center the zone
    const rect = ref.current.getBoundingClientRect()
    const [centerX, centerY] = [rect.width / prevZoom / 2, rect.height / prevZoom / 2]
    const [zoneCenterX, zoneCenterY] = [relX + relWidth / 2, relY + relHeight / 2]
    newPosX = (centerX - zoneCenterX) * newZoom
    newPosY = (centerY - zoneCenterY) * newZoom

    setZoom(newZoom)
    setPos([newPosX, newPosY])
    setTransitionDuration(animDuration)
  }, [setZoom, setPos, setTransitionDuration, animDuration, maxZoom])

  /**
   * Calculates new translate positions for CSS transformations.
   * @param  {Number} x     Relative (rect-based) X position in pixels
   * @param  {Number} y     Relative (rect-based) Y position in pixels
   * @param  {Number} zoom  Scale value
   * @return {Array}        New X and Y positions
   */
  const getNewPosition = useCallback((x: number, y: number, newZoom: number): PositionType => {
    const [prevZoom, prevPosX, prevPosY] = [zoomRef.current, posRef.current[0], posRef.current[1]]

    if (newZoom === 1 || !ref.current) return [0, 0]

    if (newZoom > prevZoom) {
      // Get container coordinates
      const rect = ref.current.getBoundingClientRect()

      // Retrieve rectangle dimensions and mouse position
      const [centerX, centerY] = [rect.width / 2, rect.height / 2]
      const [relativeX, relativeY] = [x - rect.left - window.pageXOffset, y - rect.top - window.pageYOffset]

      // If we are zooming down, we must try to center to mouse position
      const [absX, absY] = [(centerX - relativeX) / prevZoom, (centerY - relativeY) / prevZoom]
      const ratio = newZoom - prevZoom
      return [prevPosX + absX * ratio, prevPosY + absY * ratio]
    } else {
      // If we are zooming down, we shall re-center the element
      return [(prevPosX * (newZoom - 1)) / (prevZoom - 1), (prevPosY * (newZoom - 1)) / (prevZoom - 1)]
    }
  }, [])

  /**
   * Applies a full-zoom on the specified X and Y positions
   * @param  {Number} x Relative (rect-based) X position in pixels
   * @param  {Number} y Relative (rect-based) Y position in pixels
   */
  const fullZoomInOnPosition = useCallback((x: number, y: number) => {
    const zoom = maxZoom

    setPos(getNewPosition(x, y, zoom))
    setZoom(zoom)
    setTransitionDuration(animDuration)
  }, [setPos, setZoom, setTransitionDuration, getNewPosition, animDuration, maxZoom])

  /**
   * Calculates the narrowed shift for panning actions.
   * @param  {Number} shift      Initial shift in pixels
   * @param  {Number} minLimit   Minimum limit (left or top) in pixels
   * @param  {Number} maxLimit   Maximum limit (right or bottom) in pixels
   * @param  {Number} minElement Left or top element position in pixels
   * @param  {Number} maxElement Right or bottom element position in pixels
   * @return {Number}            Narrowed shift
   */
  const getLimitedShift = useCallback((
    shift: number,
    minLimit: number,
    maxLimit: number,
    minElement: number,
    maxElement: number
  ) => {
    if (shift > 0) {
      if (minElement > minLimit) {
        // Forbid move if we are moving to left or top while we are already out minimum boudaries
        return 0
      } else if (minElement + shift > minLimit) {
        // Lower the shift if we are going out boundaries
        return minLimit - minElement
      }
    } else if (shift < 0) {
      if (maxElement < maxLimit) {
        // Forbid move if we are moving to right or bottom while we are already out maximum boudaries
        return 0
      } else if (maxElement + shift < maxLimit) {
        // Lower the shift if we are going out boundaries
        return maxLimit - maxElement
      }
    }

    return shift
  }, [])

  const getCursor = useCallback((canMoveOnX: boolean, canMoveOnY: boolean) => {
    if (canMoveOnX && canMoveOnY) {
      return 'move'
    } else if (canMoveOnX) {
      return 'ew-resize'
    } else if (canMoveOnY) {
      return 'ns-resize'
    } else {
      return 'auto'
    }
  }, [])

  /**
   * Moves the element by incrementing its position with given X and Y values.
   * @param  {Number} shiftX             Position change to apply on X axis in pixels
   * @param  {Number} shiftY             Position change to apply on Y axis in pixels
   * @param  {Number} transitionDuration Transition duration (in seconds)
   */
  const move = useCallback((shiftX: number, shiftY: number, transitionDuration = 0) => {
    if (!ref.current) return
    let newPosX = posRef.current[0]
    let newPosY = posRef.current[1]

    // Get container and container's parent coordinates
    const rect = ref.current.getBoundingClientRect()
    const parentRect = (ref.current.parentNode as HTMLElement).getBoundingClientRect()

    const [isLarger, isOutLeftBoundary, isOutRightBoundary] = [
      // Check if the element is larger than its container
      rect.width > parentRect.right - parentRect.left,
      // Check if the element is out its container left boundary
      shiftX > 0 && rect.left - parentRect.left < 0,
      // Check if the element is out its container right boundary
      shiftX < 0 && rect.right - parentRect.right > 0,
    ]

    const canMoveOnX = isLarger || isOutLeftBoundary || isOutRightBoundary
    if (canMoveOnX) {
      newPosX += getLimitedShift(shiftX, parentRect.left, parentRect.right, rect.left, rect.right)
    }

    const [isHigher, isOutTopBoundary, isOutBottomBoundary] = [
      // Check if the element is higher than its container
      rect.height > parentRect.bottom - parentRect.top,
      // Check if the element is out its container top boundary
      shiftY > 0 && rect.top - parentRect.top < 0,
      // Check if the element is out its container bottom boundary
      shiftY < 0 && rect.bottom - parentRect.bottom > 0,
    ]

    const canMoveOnY = isHigher || isOutTopBoundary || isOutBottomBoundary
    if (canMoveOnY) {
      newPosY += getLimitedShift(shiftY, parentRect.top, parentRect.bottom, rect.top, rect.bottom)
    }

    const cursor = getCursor(canMoveOnX, canMoveOnY)

    setPos([newPosX, newPosY])
    setCursor(cursor)
    setTransitionDuration(transitionDuration)
  }, [setPos, setCursor, setTransitionDuration, getCursor, getLimitedShift])

  /**
   * Check if the user is doing a double tap gesture.
   * @return {Boolean} Result of the checking
   */
  const isDoubleTapping = useCallback(() => {
    const touchTime = new Date().getTime()
    const isDoubleTap =
      touchTime - (lastTouchTimeRef.current ?? 0) < doubleTouchMaxDelay &&
      touchTime - (lastDoubleTapTimeRef.current ?? 0) > doubleTouchMaxDelay

    if (isDoubleTap) {
      lastDoubleTapTimeRef.current = touchTime
      return true
    }

    lastTouchTimeRef.current = touchTime
    return false
  }, [doubleTouchMaxDelay])

  /**
   * Trigger a decelerating movement after a mouse up or a touch end event, using the last movement shift.
   * @param  {Number} lastShiftOnX Last shift on the X axis in pixels
   * @param  {Number} lastShiftOnY Last shift on the Y axis in pixels
   */
  const startDeceleration = useCallback((lastShiftOnX: number, lastShiftOnY: number) => {
    let startTimestamp: number | null = null

    const startDecelerationMove = (timestamp: number) => {
      if (startTimestamp === null) startTimestamp = timestamp

      const progress = timestamp - startTimestamp

      // Calculates the ratio to apply on the move (used to create a non-linear deceleration)
      const ratio = (decelerationDuration - progress) / decelerationDuration

      const [shiftX, shiftY] = [lastShiftOnX * ratio, lastShiftOnY * ratio]

      // Continue animation only if time has not expired and if there is still some movement (more than 1 pixel on one axis)
      if (progress < decelerationDuration && Math.max(Math.abs(shiftX), Math.abs(shiftY)) > 1) {
        move(shiftX, shiftY, 0)
        lastRequestAnimationIdRef.current = requestAnimationFrame(startDecelerationMove)
      } else {
        lastRequestAnimationIdRef.current = null
      }
    }

    lastRequestAnimationIdRef.current = requestAnimationFrame(startDecelerationMove)
  }, [move, decelerationDuration])

  /**
   * Resets the component to its initial state.
   */
  const reset = useCallback(() => {
    setZoom(initialZoom)
    setCursor(defaultCursor)
    setTransitionDuration(animDuration)
    setPos(defaultPos)
  }, [setZoom, setCursor, setTransitionDuration, setPos, initialZoom, animDuration]);

  /**
   * Event handler on double click.
   * @param  {MouseEvent} event Mouse event
   */
  const handleDoubleClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
    event.preventDefault()
    if (!allowZoom) return

    if (zoomRef.current === minZoom) {
      fullZoomInOnPosition(event.pageX, event.pageY)
    } else {
      reset()
    }
  }, [fullZoomInOnPosition, reset, allowZoom, minZoom])

  /**
   * Event handler on scroll.
   * @param  {MouseEvent} event Mouse event
   */
  const handleMouseWheel = useCallback((event: WheelEvent) => {
    event.preventDefault()
    if (!allowZoom || !allowWheel) return

    // Use the scroll event delta to determine the zoom velocity
    const velocity = (-event.deltaY * scrollVelocity) / 100

    // Set the new zoom level
    const newZoom = Math.max(Math.min(zoomRef.current + velocity, maxZoom), minZoom)

    let newPosition = posRef.current
    if (newZoom !== zoomRef.current) {
      newPosition = newZoom !== minZoom ? getNewPosition(event.pageX, event.pageY, newZoom) : defaultPos
    }

    setZoom(newZoom)
    setPos(newPosition)
    setTransitionDuration(0.05)
  }, [getNewPosition, setZoom, setPos, setTransitionDuration, allowZoom, allowWheel, maxZoom, minZoom, scrollVelocity])

  /**
   * Event handler on mouse down.
   * @param  {MouseEvent} event Mouse event
   */
  const handleMouseStart = useCallback((event: MouseEvent) => {
    event.preventDefault()
    if (!allowPan || ignoredMouseButtons.includes(event.button)) return

    if (lastRequestAnimationIdRef.current) cancelAnimationFrame(lastRequestAnimationIdRef.current)
    lastCursorRef.current = [event.pageX, event.pageY]
  }, [allowPan, ignoredMouseButtons]);

  /**
   * Event handler on mouse move.
   * @param  {MouseEvent} event Mouse event
   */
  const handleMouseMove = useCallback((event: MouseEvent) => {
    event.preventDefault()

    if (!allowPan || !lastCursorRef.current) return

    const [posX, posY] = [event.pageX, event.pageY]
    const shiftX = posX - lastCursorRef.current[0]
    const shiftY = posY - lastCursorRef.current[1]

    move(shiftX, shiftY, 0)

    lastCursorRef.current = [posX, posY]
    lastShiftRef.current = [shiftX, shiftY]
  }, [move, allowPan]);

  /**
   * Event handler on mouse up or mouse out.
   * @param  {MouseEvent} event Mouse event
   */
  const handleMouseStop = useCallback((event: MouseEvent) => {
    event.preventDefault()

    if (lastShiftRef.current) {
      // Use the last shift to make a decelerating movement effect
      startDeceleration(lastShiftRef.current[0], lastShiftRef.current[1])
      lastShiftRef.current = null
    }

    lastCursorRef.current = null
    setCursor('auto')
  }, [startDeceleration])

  /**
   * Event handler on touch start.
   * Zoom-in at the maximum scale if a double tap is detected.
   * @param  {TouchEvent} event Touch event
   */
  const handleTouchStart = useCallback((event: TouchEvent) => {
    const isThisDoubleTapping = isDoubleTapping()
    const isMultiTouch = event.touches.length > 1

    if (!allowTouchEvents) event.preventDefault()

    if (lastRequestAnimationIdRef.current) cancelAnimationFrame(lastRequestAnimationIdRef.current)

    const [posX, posY] = [event.touches[0].pageX, event.touches[0].pageY]

    if (isMultiTouch) {
      lastTouchRef.current = [posX, posY]
      return
    }

    if (isThisDoubleTapping && allowZoom) {
      if (zoomRef.current === minZoom) {
        fullZoomInOnPosition(posX, posY)
      } else {
        reset()
      }

      return
    }

    // Don't save the last touch if we are starting a simple touch move while panning is disabled
    if (allowPan) lastTouchRef.current = [posX, posY]
  }, [
    fullZoomInOnPosition,
    reset,
    isDoubleTapping,
    allowZoom,
    allowTouchEvents,
    allowPan,
    minZoom
  ])

  /**
   * Event handler on touch move.
   * Either move the element using one finger or zoom-in with a two finger pinch.
   * @param  {TouchEvent} event Touch move
   */
  const handleTouchMove = useCallback((event: TouchEvent) => {
    if (!allowTouchEvents) event.preventDefault()
    if (!lastTouchRef.current) return

    if (event.touches.length === 1) {
      const [posX, posY] = [event.touches[0].pageX, event.touches[0].pageY]
      // If we detect only one point, we shall just move the element
      const shiftX = posX - lastTouchRef.current[0]
      const shiftY = posY - lastTouchRef.current[1]

      move(shiftX, shiftY)
      lastShiftRef.current = [shiftX, shiftY]

      // Save data for the next move
      lastTouchRef.current = [posX, posY]
      lastTouchDistanceRef.current = null
    } else if (event.touches.length > 1) {
      let newZoom = zoomRef.current
      // If we detect two points, we shall zoom up or down
      const [pos1X, pos1Y] = [event.touches[0].pageX, event.touches[0].pageY]
      const [pos2X, pos2Y] = [event.touches[1].pageX, event.touches[1].pageY]
      const distance = Math.sqrt(Math.pow(pos2X - pos1X, 2) + Math.pow(pos2Y - pos1Y, 2))

      if (lastTouchDistanceRef.current && distance && distance !== lastTouchDistanceRef.current) {
        if (allowZoom) {
          newZoom += (distance - lastTouchDistanceRef.current) / 100
          if (newZoom > maxZoom) {
            newZoom = maxZoom
          } else if (newZoom < minZoom) {
            newZoom = minZoom
          }
        }

        // Change position using the center point between the two fingers
        const [centerX, centerY] = [(pos1X + pos2X) / 2, (pos1Y + pos2Y) / 2]
        const newPos = getNewPosition(centerX, centerY, newZoom)

        setZoom(newZoom)
        setPos(newPos)
        setTransitionDuration(0)
      }

      // Save data for the next move
      lastTouchRef.current = [pos1X, pos1Y]
      lastTouchDistanceRef.current = distance
    }
  }, [
    getNewPosition,
    move,
    setPos,
    setTransitionDuration,
    setZoom,
    allowZoom,
    allowTouchEvents,
    maxZoom,
    minZoom
  ])

  /**
   * Event handler on touch end or touch cancel.
   * @param  {TouchEvent} event Touch move
   */
  const handleTouchStop = useCallback(() => {
    if (lastShiftRef.current) {
      // Use the last shift to make a decelerating movement effect
      startDeceleration(lastShiftRef.current[0], lastShiftRef.current[1])
      lastShiftRef.current = null
    }

    lastTouchRef.current = null
    lastTouchDistanceRef.current = null
  }, [startDeceleration])

  // Imperative Ref methods
  useImperativeHandle(forwardedRef, () => ({
    getZoom,
    zoomIn,
    reset,
    move,
    zoomOut,
    zoomToZone,
    setZoom,
    setPos,
  }))

  useEffect(() => {
    const refCurrentValue = ref.current
    const hasMouseDevice = window.matchMedia('(pointer: fine)').matches

    refCurrentValue?.addEventListener('wheel', handleMouseWheel, { passive: false })
    if (hasMouseDevice) {
      // Apply mouse events only to devices which include an accurate pointing device
      refCurrentValue?.addEventListener('mousedown', handleMouseStart, { passive: false })
      refCurrentValue?.addEventListener('mousemove', handleMouseMove, { passive: false })
      refCurrentValue?.addEventListener('mouseup', handleMouseStop, { passive: false })
      refCurrentValue?.addEventListener('mouseleave', handleMouseStop, { passive: false })
    } else {
      // Apply touch events to all other devices
      refCurrentValue?.addEventListener('touchstart', handleTouchStart, { passive: false })
      refCurrentValue?.addEventListener('touchmove', handleTouchMove, { passive: false })
      refCurrentValue?.addEventListener('touchend', handleTouchStop, { passive: false })
      refCurrentValue?.addEventListener('touchcancel', handleTouchStop, { passive: false })
    }

    return () => {
      refCurrentValue?.removeEventListener('wheel', handleMouseWheel)
      if (hasMouseDevice) {
        refCurrentValue?.removeEventListener('mousedown', handleMouseStart)
        refCurrentValue?.removeEventListener('mousemove', handleMouseMove)
        refCurrentValue?.removeEventListener('mouseup', handleMouseStop)
        refCurrentValue?.removeEventListener('mouseleave', handleMouseStop)
      } else {
        refCurrentValue?.removeEventListener('touchstart', handleTouchStart)
        refCurrentValue?.removeEventListener('touchmove', handleTouchMove)
        refCurrentValue?.removeEventListener('touchend', handleTouchStop)
        refCurrentValue?.removeEventListener('touchcancel', handleTouchStop)
      }
    }
  }, [
    handleMouseWheel,
    handleMouseStart,
    handleMouseMove,
    handleMouseStop,
    handleTouchStart,
    handleTouchMove,
    handleTouchStop
  ])

  const attr = {
    ...divProps,
    ref,
    onDoubleClick: handleDoubleClick,
    style: {
      ...divProps.style,
      cursor: cursor,
      willChange: 'transform',
      transition: `transform ease-out ${transitionRef.current}s`,
      touchAction: allowParentPanning && zoomRef.current === 1 ? 'pan-x pan-y' : 'none',
      transform: `translate3d(${posRef.current[0]}px, ${posRef.current[1]}px, 0) scale(${zoomRef.current})`,
    },
  }

  return <div {...attr}>{children}</div>
})

export default PrismaZoom


================================================
FILE: src/types.ts
================================================
export type Ref = {
  getZoom: () => number
  zoomIn: (zoom: number) => void
  zoomOut: (zoom: number) => void
  move: (shiftX: number, shiftY: number, transitionDuration?: number) => void
  reset: VoidFunction
  zoomToZone: (relX: number, relY: number, relWidth: number, relHeight: number) => void
}

export type Props = NonNullable<React.PropsWithChildren> &
  React.HTMLAttributes<HTMLDivElement> & {
    /**  Minimum zoom ratio */
    minZoom?: number
    /**
     * Maximum zoom ratio
     */
    maxZoom?: number
    /**
     * Initial zoom ratio
     */
    initialZoom?: number
    /**
     * Zoom increment or decrement on each scroll wheel detection
     */
    scrollVelocity?: number
    /**
     * Function called each time the zoom value changes
     */
    onZoomChange?: (zoom: number) => void
    /**
     * Function called each time the posX or posY value changes (aka images was panned)
     */
    onPanChange?: (props: { posX: number; posY: number }) => void
    /**
     * Animation duration (in seconds)
     */
    animDuration?: number
    /**
     * Max delay between two taps to consider a double tap (in milliseconds)
     */
    doubleTouchMaxDelay?: number
    /**
     * Decelerating movement duration after a mouse up or a touch end event (in milliseconds)
     */
    decelerationDuration?: number
    /**
     * Enable or disable zooming in place
     */
    allowZoom?: boolean
    /**
     * Enable or disable panning in place
     */
    allowPan?: boolean
    /**
     * By default, all touch events are caught (if set to true touch events propagate)
     */
    allowTouchEvents?: boolean
    /**
     * By default, page cannot scroll with touch events
     */
    allowParentPanning?: boolean
    /**
     * Enable or disable mouse wheel and touchpad zooming in place
     */
    allowWheel?: boolean
    /**
     * Optional array of ignored mouse buttons allows to prevent panning for specific mouse buttons. By default all mouse buttons are enabled
     * https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/button#value
     */
    ignoredMouseButtons?: number[]
  }

export type PositionType = [number, number]

export type CursorType = React.CSSProperties['cursor']


================================================
FILE: tsconfig.json
================================================
{
  "include": ["src/index.tsx", "src/types.ts"],
  "compilerOptions": {
    "outDir": "dist/esm",
    "module": "esnext",
    "target": "es5",
    "lib": ["es6", "dom", "esnext"],
    "jsx": "react-jsx",
    "declaration": true,
    "moduleResolution": "node",
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "esModuleInterop": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "strictNullChecks": true
  }
}
Download .txt
gitextract_nqmvr7bf/

├── .eslintrc.json
├── .gitignore
├── .npmignore
├── .npmrc
├── .nvmrc
├── .prettierignore
├── .prettierrc
├── CHANGELOG.md
├── LICENSE.md
├── README.md
├── demo/
│   ├── index.html
│   ├── package.json
│   ├── src/
│   │   ├── App/
│   │   │   ├── App.css
│   │   │   └── index.tsx
│   │   ├── index.d.ts
│   │   └── index.tsx
│   ├── tsconfig.json
│   └── types.d.ts
├── package.json
├── src/
│   ├── index.test.js
│   ├── index.tsx
│   └── types.ts
└── tsconfig.json
Download .txt
SYMBOL INDEX (4 symbols across 1 files)

FILE: src/types.ts
  type Ref (line 1) | type Ref = {
  type Props (line 10) | type Props = NonNullable<React.PropsWithChildren> &
  type PositionType (line 73) | type PositionType = [number, number]
  type CursorType (line 75) | type CursorType = React.CSSProperties['cursor']
Condensed preview — 23 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (59K chars).
[
  {
    "path": ".eslintrc.json",
    "chars": 505,
    "preview": "{\n  \"extends\": [\n    \"eslint:recommended\",\n    \"plugin:@typescript-eslint/recommended\",\n    \"plugin:react-hooks/recommen"
  },
  {
    "path": ".gitignore",
    "chars": 49,
    "preview": "node_modules\ndist\n.DS_Store\n.cache\n.parcel-cache\n"
  },
  {
    "path": ".npmignore",
    "chars": 98,
    "preview": "src\ndemo\ntests\n.babelrc\n.eslintrc.json\n.gitignore\n.nvmrc\n.prettierignore\n.prettierrc\ntsconfig.json"
  },
  {
    "path": ".npmrc",
    "chars": 21,
    "preview": "tag-version-prefix=\"\""
  },
  {
    "path": ".nvmrc",
    "chars": 6,
    "preview": "18.1.0"
  },
  {
    "path": ".prettierignore",
    "chars": 4,
    "preview": "*.md"
  },
  {
    "path": ".prettierrc",
    "chars": 63,
    "preview": "{\n  \"singleQuote\": true,\n  \"printWidth\": 120,\n  \"semi\": false\n}"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 3227,
    "preview": "# Changelog\n\n## [3.3.5] - 2023-08-12\n- Fix hebavior of `allowPan` and `allowZoom` props (thanks [Robert Brownstein](http"
  },
  {
    "path": "LICENSE.md",
    "chars": 804,
    "preview": "Copyright © 2004-2013 by Internet Systems Consortium, Inc. (“ISC”)\nCopyright © 1995-2003 by Internet Software Consortium"
  },
  {
    "path": "README.md",
    "chars": 5054,
    "preview": "# react-prismazoom\n\n## About\n\nA pan and zoom component for React, using CSS transformations.\n\nDepends only upon prop-typ"
  },
  {
    "path": "demo/index.html",
    "chars": 839,
    "preview": "<!DOCTYPE html>\n<html>\n  <head>\n    <title>react-prismazoom</title>\n    <meta charset=\"utf-8\" />\n    <meta name=\"viewpor"
  },
  {
    "path": "demo/package.json",
    "chars": 468,
    "preview": "{\n  \"name\": \"demo\",\n  \"scripts\": {\n    \"start\": \"parcel index.html --no-cache\",\n    \"build\": \"parcel build index.html --"
  },
  {
    "path": "demo/src/App/App.css",
    "chars": 2431,
    "preview": "html {\n  font-size: 80%;\n}\n\n@media (min-width: 480px) {\n  html {\n    font-size: 100%;\n  }\n}\n\nbody {\n  min-width: 320px;\n"
  },
  {
    "path": "demo/src/App/index.tsx",
    "chars": 5849,
    "preview": "import React, { ComponentRef, MouseEvent, useCallback, useRef, useState } from 'react'\n\nimport PrismaZoom from '../../.."
  },
  {
    "path": "demo/src/index.d.ts",
    "chars": 23,
    "preview": "declare module '*.jpg'\n"
  },
  {
    "path": "demo/src/index.tsx",
    "chars": 194,
    "preview": "import React from 'react'\nimport ReactDOM from 'react-dom'\n\nimport App from './App'\n\nReactDOM.render(\n  <React.StrictMod"
  },
  {
    "path": "demo/tsconfig.json",
    "chars": 137,
    "preview": "{\n  \"extends\": \"../tsconfig.json\",\n  \"compilerOptions\": {\n    \"removeComments\": true,\n    \"sourceMap\": true,\n    \"types\""
  },
  {
    "path": "demo/types.d.ts",
    "chars": 23,
    "preview": "declare module '*.jpg'\n"
  },
  {
    "path": "package.json",
    "chars": 1629,
    "preview": "{\n  \"name\": \"react-prismazoom\",\n  \"version\": \"3.3.5\",\n  \"description\": \"A pan and zoom component for React, using CSS tr"
  },
  {
    "path": "src/index.test.js",
    "chars": 9017,
    "preview": "// TODO: Rework all tests using a different library\n\n// import React from 'react'\n// import { mount, configure } from 'e"
  },
  {
    "path": "src/index.tsx",
    "chars": 22674,
    "preview": "import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'\nimport type { P"
  },
  {
    "path": "src/types.ts",
    "chars": 2219,
    "preview": "export type Ref = {\n  getZoom: () => number\n  zoomIn: (zoom: number) => void\n  zoomOut: (zoom: number) => void\n  move: ("
  },
  {
    "path": "tsconfig.json",
    "chars": 472,
    "preview": "{\n  \"include\": [\"src/index.tsx\", \"src/types.ts\"],\n  \"compilerOptions\": {\n    \"outDir\": \"dist/esm\",\n    \"module\": \"esnext"
  }
]

About this extraction

This page contains the full source code of the sylvaindubus/react-prismazoom GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 23 files (54.5 KB), approximately 16.0k tokens, and a symbol index with 4 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!