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'

A text that can be zoomed and dragged

``` ### 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 ================================================ react-prismazoom
================================================ 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>(null) const zoomCounterRef = useRef(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 (

react-prismazoom

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

The Raft of the Medusa

Théodore Géricault

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.

Go to Wikipedia.

Tip: double-click on this card to zoom. 😉
100%

Vesuvius in Eruption

Joseph Mallord William Turner

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.

) } 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( , 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 ", "contributors": [ "Erick Estevão Riva Pramio " ], "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 = '
' // 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( // //
//
// ) // 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((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(null) // Last request animation frame identifier const lastRequestAnimationIdRef = useRef() // Last touch time in milliseconds const lastTouchTimeRef = useRef() // Last double tap time (used to limit multiple double tap) in milliseconds const lastDoubleTapTimeRef = useRef() // Last shifted position const lastShiftRef = useRef() // Last calculated distance between two fingers in pixels const lastTouchDistanceRef = useRef() // Last cursor position const lastCursorRef = useRef() // Last touch position const lastTouchRef = useRef() // Current zoom level const zoomRef = useRef(initialZoom) // Current position const posRef = useRef(defaultPos) // Current transition duration const transitionRef = useRef(animDuration) const [cursor, setCursor] = useState(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) => { 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
{children}
}) 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.HTMLAttributes & { /** 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 } }