react-easy-panzoom
Version:
Wrapper to enable pan and zoom for any React component
833 lines (702 loc) • 24.8 kB
JavaScript
// @flow
import * as React from 'react'
import warning from 'warning'
import type { Coordinates, BoundCoordinates, TransformationParameters, TransformationMatrix } from './maths'
import { TransformMatrix, getTransformedBoundingBox, getScaleMultiplier, boundCoordinates } from './maths'
import { captureTextSelection, releaseTextSelection } from './events'
type OnStateChangeData = {
x: number,
y: number,
scale: number,
angle: number
}
type Props = {
zoomSpeed: number,
doubleZoomSpeed: number,
disabled?: boolean,
autoCenter?: boolean,
autoCenterZoomLevel?: number,
disableKeyInteraction?: boolean,
disableDoubleClickZoom?: boolean,
disableScrollZoom?: boolean,
realPinch?: boolean,
keyMapping?: { [string]: { x: number, y: number, z: number }},
minZoom: number,
maxZoom: number,
preventPan: (event: SyntheticTouchEvent<HTMLDivElement> | MouseEvent, x: number, y: number) => boolean,
noStateUpdate: boolean,
boundaryRatioVertical: number,
boundaryRatioHorizontal: number,
onPanStart?: (any) => void,
onPan?: (any) => void,
onPanEnd?: (any) => void,
onStateChange?: (data: OnStateChangeData) => void,
} & React.ElementProps<'div'>
type State = {
x: number,
y: number,
scale: number,
angle: number
}
const getTransformMatrixString = (transformationMatrix: TransformationMatrix) => {
const { a, b, c, d, x, y } = transformationMatrix
return `matrix(${a}, ${b}, ${c}, ${d}, ${x}, ${y})`
}
class PanZoom extends React.Component<Props, State> {
static defaultProps = {
zoomSpeed: 1,
doubleZoomSpeed: 1.75,
disabled: false,
minZoom: 0,
maxZoom: Infinity,
noStateUpdate: true,
boundaryRatioVertical: 0.8,
boundaryRatioHorizontal: 0.8,
disableDoubleClickZoom: false,
disableScrollZoom: false,
preventPan: () => false,
}
container = React.createRef<HTMLDivElement>()
dragContainer = React.createRef<HTMLDivElement>()
mousePos = {
x: 0,
y: 0
}
panning = false
touchInProgress = false
panStartTriggered = false
pinchZoomLength = 0
prevPanPosition = {
x: 0,
y: 0,
}
frameAnimation = null
intermediateFrameAnimation = null
transformMatrixString = `matrix(1, 0, 0, 1, 0, 0)`
intermediateTransformMatrixString = `matrix(1, 0, 0, 1, 0, 0)`
state: State = {
x: 0,
y: 0,
scale: 1,
angle: 0,
}
componentDidMount(): void {
const { autoCenter, autoCenterZoomLevel, minZoom, maxZoom } = this.props
if (this.container.current) {
this.container.current.addEventListener('wheel', this.onWheel, { passive: false })
}
if (maxZoom < minZoom) {
throw new Error('[PanZoom]: maxZoom props cannot be inferior to minZoom')
}
if (autoCenter) {
this.autoCenter(autoCenterZoomLevel, false)
}
}
componentDidUpdate(prevProps: Props, prevState: State): void {
if (prevProps.autoCenter !== this.props.autoCenter
&& this.props.autoCenter) {
this.autoCenter(this.props.autoCenterZoomLevel)
}
if (
(prevState.x !== this.state.x
|| prevState.y !== this.state.y
|| prevState.scale !== this.state.scale
|| prevState.angle !== this.state.angle)
&& this.props.onStateChange
) {
this.props.onStateChange({
x: this.state.x,
y: this.state.y,
scale: this.state.scale,
angle: this.state.angle
})
}
}
componentWillUnmount(): void {
this.cleanMouseListeners()
this.cleanTouchListeners()
releaseTextSelection()
if (this.container.current) {
this.container.current.removeEventListener('wheel', this.onWheel, { passive: false })
}
}
onDoubleClick = (e: MouseEvent) => {
const { onDoubleClick, disableDoubleClickZoom, doubleZoomSpeed } = this.props
if (typeof onDoubleClick === 'function') {
onDoubleClick(e)
}
if (disableDoubleClickZoom) {
return
}
const offset = this.getOffset(e)
this.zoomTo(offset.x, offset.y, doubleZoomSpeed)
}
onMouseDown = (e: MouseEvent) => {
const { preventPan, onMouseDown } = this.props
if (typeof onMouseDown === 'function') {
onMouseDown(e)
}
if (this.props.disabled) {
return
}
// Touch events fire mousedown on modern browsers, but it should not
// be considered as we will handle touch event separately
if (this.touchInProgress) {
e.stopPropagation()
return false
}
const isLeftButton = ((e.button === 1 && window.event !== null) || e.button === 0)
if (!isLeftButton) {
return
}
const offset = this.getOffset(e)
// check if there is nothing preventing the pan
if (preventPan && preventPan(e, offset.x, offset.y)) {
return
}
this.mousePos = {
x: offset.x,
y: offset.y,
}
// keep the current pan value in memory to allow noStateUpdate panning
this.prevPanPosition = {
x: this.state.x,
y: this.state.y,
}
this.panning = true
this.setMouseListeners()
// Prevent text selection
captureTextSelection()
}
onMouseMove = (e: MouseEvent) => {
if (this.panning) {
const { noStateUpdate } = this.props
// TODO disable if using touch event
this.triggerOnPanStart(e)
const offset = this.getOffset(e)
const dx = offset.x - this.mousePos.x
const dy = offset.y - this.mousePos.y
this.mousePos = {
x: offset.x,
y: offset.y,
}
this.moveBy(dx, dy, noStateUpdate)
this.triggerOnPan(e)
}
}
onMouseUp = (e: MouseEvent) => {
const { noStateUpdate } = this.props
// if using noStateUpdate we still need to set the new values in the state
if (noStateUpdate) {
this.setState(({ x: this.prevPanPosition.x, y: this.prevPanPosition.y }))
}
this.triggerOnPanEnd(e)
this.cleanMouseListeners()
this.panning = false
releaseTextSelection()
}
onWheel = (e: WheelEvent) => {
const { disableScrollZoom, disabled, zoomSpeed } = this.props
if (disableScrollZoom || disabled) {
return
}
const scale = getScaleMultiplier(e.deltaY, zoomSpeed)
const offset = this.getOffset(e)
this.zoomTo(offset.x, offset.y, scale)
e.preventDefault()
}
onKeyDown = (e: SyntheticKeyboardEvent<HTMLDivElement>) => {
const { keyMapping, disableKeyInteraction, onKeyDown } = this.props
if (typeof onKeyDown === 'function') {
onKeyDown(e)
}
if (disableKeyInteraction) {
return
}
const keys = {
'38': { x: 0, y: -1, z: 0 }, // up
'40': { x: 0, y: 1, z: 0 }, // down
'37': { x: -1, y: 0, z: 0 }, // left
'39': { x: 1, y: 0, z: 0 }, // right
'189': { x: 0, y: 0, z: 1 }, // zoom out
'109': { x: 0, y: 0, z: 1 }, // zoom out
'187': { x: 0, y: 0, z: -1 }, // zoom in
'107': { x: 0, y: 0, z: -1 }, // zoom in
...keyMapping,
}
const mappedCoords = keys[e.keyCode]
if (mappedCoords) {
const { x, y, z } = mappedCoords
e.preventDefault()
e.stopPropagation()
if ((x || y) && this.container.current) {
const containerRect = this.container.current.getBoundingClientRect()
const offset = Math.min(containerRect.width, containerRect.height)
const moveSpeedRatio = 0.05
const dx = offset * moveSpeedRatio * x
const dy = offset * moveSpeedRatio * y
this.moveBy(dx, dy)
}
if (z) {
this.centeredZoom(z)
}
}
}
onKeyUp = (e: SyntheticKeyboardEvent<HTMLDivElement>) => {
const { disableKeyInteraction, onKeyDown } = this.props
if (typeof onKeyDown === 'function') {
onKeyDown(e)
}
if (disableKeyInteraction) {
return
}
if (this.prevPanPosition && (this.prevPanPosition.x !== this.state.x || this.prevPanPosition.y !== this.state.y)) {
this.setState({ x: this.prevPanPosition.x, y: this.prevPanPosition.y })
}
}
onTouchStart = (e: SyntheticTouchEvent<HTMLDivElement>) => {
const { preventPan, onTouchStart, disabled } = this.props
if (typeof onTouchStart === 'function') {
onTouchStart(e)
}
if (disabled) {
return
}
if (e.touches.length === 1) {
// Drag
const touch = e.touches[0]
const offset = this.getOffset(touch)
if (preventPan && preventPan(e, offset.x, offset.y)) {
return
}
this.mousePos = {
x: offset.x,
y: offset.y,
}
// keep the current pan value in memory to allow noStateUpdate panning
this.prevPanPosition = {
x: this.state.x,
y: this.state.y,
}
this.touchInProgress = true
this.setTouchListeners()
} else if (e.touches.length === 2) {
// pinch
this.pinchZoomLength = this.getPinchZoomLength(e.touches[0], e.touches[1])
this.touchInProgress = true
this.setTouchListeners()
}
}
onToucheMove = (e: TouchEvent) => {
const { realPinch, noStateUpdate, zoomSpeed } = this.props
if (e.touches.length === 1) {
e.stopPropagation()
const touch = e.touches[0]
const offset = this.getOffset(touch)
const dx = offset.x - this.mousePos.x
const dy = offset.y - this.mousePos.y
if (dx !== 0 || dy !== 0) {
this.triggerOnPanStart(e)
}
this.mousePos = {
x: offset.x,
y: offset.y,
}
this.moveBy(dx, dy, noStateUpdate)
this.triggerOnPan(e)
} else if (e.touches.length === 2) {
const finger1 = e.touches[0]
const finger2 = e.touches[1]
const currentPinZoomLength = this.getPinchZoomLength(finger1, finger2)
let scaleMultiplier = 1
if (realPinch) {
scaleMultiplier = currentPinZoomLength / this.pinchZoomLength
} else {
let delta = 0
if (currentPinZoomLength < this.pinchZoomLength) {
delta = 1
} else if (currentPinZoomLength > this.pinchZoomLength) {
delta = -1
}
scaleMultiplier = getScaleMultiplier(delta, zoomSpeed)
}
this.mousePos = {
x: (finger1.clientX + finger2.clientX) / 2,
y: (finger1.clientY + finger2.clientY) / 2,
}
this.zoomTo(this.mousePos.x, this.mousePos.y, scaleMultiplier)
this.pinchZoomLength = currentPinZoomLength
e.stopPropagation()
}
}
onTouchEnd = (e: TouchEvent) => {
if (e.touches.length > 0) {
const offset = this.getOffset(e.touches[0])
this.mousePos = {
x: offset.x,
y: offset.y,
}
// when removing a finger we don't go through onTouchStart
// thus we need to set the prevPanPosition here
this.prevPanPosition = {
x: this.state.x,
y: this.state.y,
}
} else {
const { noStateUpdate } = this.props
if (noStateUpdate) {
this.setState(({ x: this.prevPanPosition.x, y: this.prevPanPosition.y }))
}
this.touchInProgress = false
this.triggerOnPanEnd(e)
this.cleanTouchListeners()
}
}
setMouseListeners = () => {
document.addEventListener('mousemove', this.onMouseMove)
document.addEventListener('mouseup', this.onMouseUp)
}
cleanMouseListeners = () => {
document.removeEventListener('mousemove', this.onMouseMove)
document.removeEventListener('mouseup', this.onMouseUp)
if (this.frameAnimation) {
window.cancelAnimationFrame(this.frameAnimation)
this.frameAnimation = 0
}
if (this.intermediateFrameAnimation) {
window.cancelAnimationFrame(this.intermediateFrameAnimation)
this.intermediateFrameAnimation = 0
}
}
setTouchListeners = () => {
document.addEventListener('touchmove', this.onToucheMove)
document.addEventListener('touchend', this.onTouchEnd)
document.addEventListener('touchcancel', this.onTouchEnd)
}
cleanTouchListeners = () => {
document.removeEventListener('touchmove', this.onToucheMove)
document.removeEventListener('touchend', this.onTouchEnd)
document.removeEventListener('touchcancel', this.onTouchEnd)
if (this.frameAnimation) {
window.cancelAnimationFrame(this.frameAnimation)
this.frameAnimation = 0
}
if (this.intermediateFrameAnimation) {
window.cancelAnimationFrame(this.intermediateFrameAnimation)
this.intermediateFrameAnimation = 0
}
}
triggerOnPanStart = (e: MouseEvent | TouchEvent) => {
const { onPanStart } = this.props
if (!this.panStartTriggered && onPanStart && typeof onPanStart === 'function') {
onPanStart(e)
}
this.panStartTriggered = true
}
triggerOnPan = (e: MouseEvent | TouchEvent) => {
const { onPan } = this.props
if (typeof onPan === 'function') {
onPan(e)
}
}
triggerOnPanEnd = (e: MouseEvent | TouchEvent) => {
const { onPanEnd } = this.props
this.panStartTriggered = false
if (typeof onPanEnd === 'function') {
onPanEnd(e)
}
}
getPinchZoomLength = (finger1: Touch, finger2: Touch): number => {
return Math.sqrt(
(finger1.clientX - finger2.clientX) * (finger1.clientX - finger2.clientX) +
(finger1.clientY - finger2.clientY) * (finger1.clientY - finger2.clientY)
)
}
getContainer = (): HTMLDivElement => {
const { current: container } = this.container
if (!container) {
throw new Error("Could not find container DOM element.")
}
return container
}
getDragContainer = (): HTMLDivElement => {
const { current: dragContainer } = this.dragContainer
if (!dragContainer) {
throw new Error("Could not find dragContainer DOM element.")
}
return dragContainer
}
autoCenter = (zoomLevel: number = 1, animate: boolean = true) => {
const container = this.getContainer()
const dragContainer = this.getDragContainer()
const { minZoom, maxZoom } = this.props
const containerRect = container.getBoundingClientRect()
const { clientWidth, clientHeight } = dragContainer
const widthRatio = containerRect.width / clientWidth
const heightRatio = containerRect.height / clientHeight
let scale = Math.min(widthRatio, heightRatio) * zoomLevel
if (scale < minZoom) {
console.warn(`[PanZoom]: initial zoomLevel produces a scale inferior to minZoom, reverted to default: ${minZoom}. Consider using a zoom level > ${minZoom}`)
scale = minZoom
}
else if (scale > maxZoom) {
console.warn(`[PanZoom]: initial zoomLevel produces a scale superior to maxZoom, reverted to default: ${maxZoom}. Consider using a zoom level < ${maxZoom}`)
scale = maxZoom
}
const x = (containerRect.width - (clientWidth * scale)) / 2
const y = (containerRect.height - (clientHeight * scale)) / 2
let afterStateUpdate = undefined
if (!animate) {
const transition = dragContainer.style.transition
dragContainer.style.transition = "none"
afterStateUpdate = () => {
setTimeout(() => {
const dragContainer = this.getDragContainer()
dragContainer.style.transition = transition
}, 0)
}
}
this.prevPanPosition = { x, y }
this.setState({ x, y, scale, angle: 0 }, afterStateUpdate)
}
moveByRatio = (x: number, y: number, moveSpeedRatio: number = 0.05) => {
const container = this.getContainer()
const containerRect = container.getBoundingClientRect()
const offset = Math.min(containerRect.width, containerRect.height)
const dx = offset * moveSpeedRatio * x
const dy = offset * moveSpeedRatio * y
this.moveBy(dx, dy, false)
}
moveBy = (dx: number, dy: number, noStateUpdate?: boolean = true) => {
const { x, y, scale, angle } = this.state
// Allow better performance by not updating the state on every change
if (noStateUpdate) {
const { x: prevTransformX, y: prevTransformY } = this.getTransformMatrix(this.prevPanPosition.x, this.prevPanPosition.y, scale, angle)
const { a, b, c, d, x: transformX, y: transformY} = this.getTransformMatrix(this.prevPanPosition.x + dx, this.prevPanPosition.y + dy, scale, angle)
const { boundX, boundY, offsetX, offsetY } = this.getBoundCoordinates({x: transformX, y: transformY }, { angle, scale, offsetX: this.prevPanPosition.x + dx, offsetY: this.prevPanPosition.y + dy })
const intermediateX = prevTransformX + (prevTransformX - boundX) / 2
const intermediateY = prevTransformY + (prevTransformY - boundY) / 2
this.intermediateTransformMatrixString = getTransformMatrixString({ a, b, c, d, x: intermediateX, y: intermediateY })
this.transformMatrixString = getTransformMatrixString({ a, b, c, d, x: boundX, y: boundY })
// get bound x / y coords without the rotation offset
this.prevPanPosition = {
x: offsetX,
y: offsetY,
}
// only apply intermediate animation if it is different from the end result
if (this.intermediateTransformMatrixString !== this.transformMatrixString) {
this.intermediateFrameAnimation = window.requestAnimationFrame(this.applyIntermediateTransform)
}
this.frameAnimation = window.requestAnimationFrame(this.applyTransform)
}
else {
const { x: transformX, y: transformY} = this.getTransformMatrix(x + dx, y + dy, scale, angle)
const { boundX, boundY } = this.getBoundCoordinates({ x: transformX, y: transformY }, { angle, scale, offsetX: x + dx, offsetY: y + dy })
this.setState(({
x: x + dx - (transformX - boundX),
y: y + dy - (transformY - boundY),
}))
}
}
rotate = (value: number | (prevAngle: number) => number) => {
const { angle } = this.state
let newAngle: number
if (typeof value === 'function') {
newAngle = value(angle)
} else {
newAngle = value
}
this.setState({ angle: newAngle })
}
zoomAbs = (x: number, y: number, zoomLevel: number) => {
this.zoomTo(x, y, zoomLevel / this.state.scale)
}
zoomTo = (x: number, y: number, ratio: number) => {
const { minZoom, maxZoom } = this.props
const { x: transformX, y: transformY, scale, angle } = this.state
let newScale = scale * ratio
if (newScale < minZoom) {
if (scale === minZoom) {
return
}
ratio = minZoom / scale
newScale = minZoom
}
else if (newScale > maxZoom) {
if (scale === maxZoom) {
return
}
ratio = maxZoom / scale
newScale = maxZoom
}
const newX = x - ratio * (x - transformX)
const newY = y - ratio * (y - transformY)
const { boundX, boundY } = this.getBoundCoordinates({ x: newX, y: newY }, { angle, scale, offsetX: newX, offsetY: newY })
this.prevPanPosition = { x: boundX, y: boundY }
this.setState({ x: boundX, y: boundY, scale: newScale })
}
centeredZoom = (delta: number, zoomSpeed?: number) => {
const container = this.getContainer()
const scaleMultiplier = getScaleMultiplier(delta, zoomSpeed || this.props.zoomSpeed)
const containerRect = container.getBoundingClientRect()
this.zoomTo(containerRect.width / 2, containerRect.height / 2, scaleMultiplier)
}
zoomIn = (zoomSpeed?: number) => {
this.centeredZoom(-1, zoomSpeed)
}
zoomOut = (zoomSpeed?: number) => {
this.centeredZoom(1, zoomSpeed)
}
reset = () => {
this.setState({ x: 0, y: 0, scale: 1, angle: 0 })
}
getContainerBoundingRect = (): ClientRect => {
return this.getContainer().getBoundingClientRect()
}
getOffset = (e: MouseEvent | Touch): Coordinates => {
const containerRect = this.getContainerBoundingRect()
const offsetX = e.clientX - containerRect.left
const offsetY = e.clientY - containerRect.top
return { x: offsetX, y: offsetY }
}
getTransformMatrix = (x: number, y: number, scale: number, angle: number): TransformationMatrix => {
if (!this.dragContainer.current) {
return { a: scale, b: 0, c: 0, d: scale, x, y }
}
const { clientWidth, clientHeight } = this.getDragContainer()
const centerX = clientWidth / 2
const centerY = clientHeight / 2
return TransformMatrix({ angle, scale, offsetX: x, offsetY: y }, { x: centerX, y: centerY })
}
// Apply transform through rAF
applyTransform = () => {
this.getDragContainer().style.transform = this.transformMatrixString
this.frameAnimation = 0
}
// Apply intermediate transform through rAF
applyIntermediateTransform = () => {
this.getDragContainer().style.transform = this.intermediateTransformMatrixString
this.intermediateFrameAnimation = 0
}
getBoundCoordinates = (coordinates: Coordinates, transformationParameters: TransformationParameters): BoundCoordinates => {
const { x, y } = coordinates
const { enableBoundingBox, boundaryRatioVertical, boundaryRatioHorizontal } = this.props
const { offsetX = 0, offsetY = 0 } = transformationParameters
if (!enableBoundingBox) {
return {
boundX: x,
boundY: y,
offsetX: x,
offsetY: y,
}
}
const { height: containerHeight, width: containerWidth } = this.getContainerBoundingRect()
const { clientTop, clientLeft, clientWidth, clientHeight } = this.getDragContainer()
const clientBoundingBox = { top: clientTop, left: clientLeft, width: clientWidth, height: clientHeight }
return boundCoordinates(x, y,
{ vertical: boundaryRatioVertical, horizontal: boundaryRatioHorizontal },
getTransformedBoundingBox(transformationParameters, clientBoundingBox),
containerHeight, containerWidth,
offsetX, offsetY)
}
render() {
const {
children,
autoCenter,
autoCenterZoomLevel,
zoomSpeed,
doubleZoomSpeed,
disabled,
disableDoubleClickZoom,
disableScrollZoom,
disableKeyInteraction,
realPinch,
keyMapping,
minZoom,
maxZoom,
enableBoundingBox,
boundaryRatioVertical,
boundaryRatioHorizontal,
noStateUpdate,
onPanStart,
onPan,
onPanEnd,
preventPan,
style,
onDoubleClick,
onMouseDown,
onKeyDown,
onKeyUp,
onTouchStart,
onStateChange,
...restPassThroughProps
} = this.props
const { x, y, scale, angle } = this.state
const transform = getTransformMatrixString(this.getTransformMatrix(x, y, scale, angle))
if (process.env.NODE_ENV !== 'production') {
warning(
onDoubleClick === undefined || typeof onDoubleClick === 'function',
"Expected `onDoubleClick` listener to be a function, instead got a value of `%s` type.",
typeof onDoubleClick
)
warning(
onMouseDown === undefined || typeof onMouseDown === 'function',
"Expected `onMouseDown` listener to be a function, instead got a value of `%s` type.",
typeof onMouseDown
)
warning(
onKeyDown === undefined || typeof onKeyDown === 'function',
"Expected `onKeyDown` listener to be a function, instead got a value of `%s` type.",
typeof onKeyDown
)
warning(
onKeyUp === undefined || typeof onKeyUp === 'function',
"Expected `onKeyUp` listener to be a function, instead got a value of `%s` type.",
typeof onKeyUp
)
warning(
onTouchStart === undefined || typeof onTouchStart === 'function',
"Expected `onTouchStart` listener to be a function, instead got a value of `%s` type.",
typeof onTouchStart
)
}
return (
<div
ref={this.container}
{
...(disableKeyInteraction ? {} : {
tabIndex: 0, // enable onKeyDown event
})
}
onDoubleClick={this.onDoubleClick}
onMouseDown={this.onMouseDown}
// React onWheel event listener is broken on Chrome 73
// The default options for the wheel event listener has been defaulted to passive
// but this behaviour breaks the zoom feature of PanZoom.
// Until further research onWheel listener is replaced by
// this.container.addEventListener('mousewheel', this.onWheel, { passive: false })
// see Chrome motivations https://developers.google.com/web/updates/2019/02/scrolling-intervention
//onWheel={this.onWheel}
onKeyDown={this.onKeyDown}
onKeyUp={this.onKeyUp}
onTouchStart={this.onTouchStart}
style={{ cursor: disabled ? 'initial' : 'pointer', ...style }}
{...restPassThroughProps}
>
<div
ref={this.dragContainer}
style={{
display: 'inline-block',
transformOrigin: '0 0 0',
transform,
transition: 'all 0.10s linear',
willChange: 'transform',
}}
>
{children}
</div>
</div>
)
}
}
export default PanZoom