```
### 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
================================================
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.
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
}
}