/**
 * Panzoom for panning and zooming elements using CSS transforms
 * https://github.com/timmywil/panzoom
 *
 * Copyright Timmy Willison and other contributors
 * Released under the MIT license
 * https://github.com/timmywil/panzoom/blob/main/MIT-License.txt
 *
 */ import './polyfills' ; import type { PanOptions , PanzoomEvent , PanzoomEventDetail , PanzoomObject , PanzoomOptions , ZoomOptions } from './types' ; import { addPointer , getDistance , getMiddle , removePointer } from './pointers' ; import { destroyPointer , eventNames , onPointer } from './events' ; import { getDimensions , setStyle , setTransform , setTransition } from './css' ; import isAttached from './isAttached' ; import isExcluded from './isExcluded' ; import isSVGElement from './isSVGElement' ; import shallowClone from './shallowClone' ; const defaultOptions = { animate : false , canvas : false , cursor : 'move' , disablePan : false , disableZoom : false , disableXAxis : false , disableYAxis : false , duration : 200 , easing : 'ease-in-out' , exclude : [ ] , excludeClass : 'panzoom-exclude' , handleStartEvent : ( e ) => { e . preventDefault ( ) ; e . stopPropagation ( ) ; } , maxScale : 4 , minScale : 0.125 , overflow : 'hidden' , panOnlyWhenZoomed : false , relative : false , setTransform , startX : 0 , startY : 0 , startScale : 1 , step : 0.3 , touchAction : 'none' } ; function Panzoom ( elem , options ? ) { if ( ! elem ) { throw new Error ( 'Panzoom requires an element as an argument' ) ; } if ( elem . nodeType !== 1 ) { throw new Error ( 'Panzoom requires an element with a nodeType of 1' ) ; } if ( ! isAttached ( elem ) ) { throw new Error ( 'Panzoom should be called on elements that have been attached to the DOM' ) ; } options = { ... defaultOptions , ... options } ; const isSVG = isSVGElement ( elem ) ; const parent = elem . parentNode as HTMLElement | SVGElement ; parent . style . overflow = options . overflow ; parent . style . userSelect = 'none' ; parent . style . touchAction = options . touchAction ; ( options . canvas ? parent : elem ) . style . cursor = options . cursor ; elem . style . userSelect = 'none' ; elem . style . touchAction = options . touchAction ; setStyle ( elem , 'transformOrigin' , typeof options . origin === 'string' ? options . origin : isSVG ? '0 0' : '50% 50%' ) ; function resetStyle ( ) { parent . style . overflow = '' ; parent . style . userSelect = '' ; parent . style . touchAction = '' ; parent . style . cursor = '' ; elem . style . cursor = '' ; elem . style . userSelect = '' ; elem . style . touchAction = '' ; setStyle ( elem , 'transformOrigin' , '' ) ; } function setOptions ( opts = { } ) { for ( const key in opts ) { if ( opts . hasOwnProperty ( key ) ) { options [ key ] = opts [ key ] ; } } if ( opts . hasOwnProperty ( 'cursor' ) || opts . hasOwnProperty ( 'canvas' ) ) { parent . style . cursor = elem . style . cursor = '' ; ( options . canvas ? parent : elem ) . style . cursor = options . cursor ; } if ( opts . hasOwnProperty ( 'overflow' ) ) { parent . style . overflow = opts . overflow ; } if ( opts . hasOwnProperty ( 'touchAction' ) ) { parent . style . touchAction = opts . touchAction ; elem . style . touchAction = opts . touchAction ; } } let x = 0 ; let y = 0 ; let scale = 1 ; let isPanning = false ; zoom ( options . startScale , { animate : false , force : true } ) ; setTimeout ( ( ) => { pan ( options . startX , options . startY , { animate : false , force : true } ) ; } ) ; function trigger ( eventName , detail , opts ) { if ( opts . silent ) { return ; } const event = new CustomEvent ( eventName , { detail } ) ; elem . dispatchEvent ( event ) ; } function setTransformWithEvent ( eventName , opts , originalEvent ? ) { const value = { x , y , scale , isSVG , originalEvent } ; requestAnimationFrame ( ( ) => { if ( typeof opts . animate === 'boolean' ) { if ( opts . animate ) { setTransition ( elem , opts ) ; } else { setStyle ( elem , 'transition' , 'none' ) ; } } opts . setTransform ( elem , value , opts ) ; trigger ( eventName , value , opts ) ; trigger ( 'panzoomchange' , value , opts ) ; } ) ; return value ; } function constrainXY ( toX , toY , toScale , panOptions ? ) { const opts = { ... options , ... panOptions } ; const result = { x , y , opts } ; if ( ! opts . force && ( opts . disablePan || ( opts . panOnlyWhenZoomed && scale === opts . startScale ) ) ) { return result ; } toX = parseFloat ( toX as string ) ; toY = parseFloat ( toY as string ) ; if ( ! opts . disableXAxis ) { result . x = ( opts . relative ? x : 0 ) + toX ; } if ( ! opts . disableYAxis ) { result . y = ( opts . relative ? y : 0 ) + toY ; } if ( opts . contain ) { const dims = getDimensions ( elem ) ; const realWidth = dims . elem . width / scale ; const realHeight = dims . elem . height / scale ; const scaledWidth = realWidth * toScale ; const scaledHeight = realHeight * toScale ; const diffHorizontal = ( scaledWidth - realWidth ) / 2 ; const diffVertical = ( scaledHeight - realHeight ) / 2 ; if ( opts . contain === 'inside' ) { const minX = ( - dims . elem . margin . left - dims . parent . padding . left + diffHorizontal ) / toScale ; const maxX = ( dims . parent . width - scaledWidth - dims . parent . padding . left - dims . elem . margin . left - dims . parent . border . left - dims . parent . border . right + diffHorizontal ) / toScale ; result . x = Math . max ( Math . min ( result . x , maxX ) , minX ) ; const minY = ( - dims . elem . margin . top - dims . parent . padding . top + diffVertical ) / toScale ; const maxY = ( dims . parent . height - scaledHeight - dims . parent . padding . top - dims . elem . margin . top - dims . parent . border . top - dims . parent . border . bottom + diffVertical ) / toScale ; result . y = Math . max ( Math . min ( result . y , maxY ) , minY ) ; } else if ( opts . contain === 'outside' ) { const minX = ( - ( scaledWidth - dims . parent . width ) - dims . parent . padding . left - dims . parent . border . left - dims . parent . border . right + diffHorizontal ) / toScale ; const maxX = ( diffHorizontal - dims . parent . padding . left ) / toScale ; result . x = Math . max ( Math . min ( result . x , maxX ) , minX ) ; const minY = ( - ( scaledHeight - dims . parent . height ) - dims . parent . padding . top - dims . parent . border . top - dims . parent . border . bottom + diffVertical ) / toScale ; const maxY = ( diffVertical - dims . parent . padding . top ) / toScale ; result . y = Math . max ( Math . min ( result . y , maxY ) , minY ) ; } } if ( opts . roundPixels ) { result . x = Math . round ( result . x ) ; result . y = Math . round ( result . y ) ; } return result ; } function constrainScale ( toScale , zoomOptions ? ) { const opts = { ... options , ... zoomOptions } ; const result = { scale , opts } ; if ( ! opts . force && opts . disableZoom ) { return result ; } let minScale = options . minScale ; let maxScale = options . maxScale ; if ( opts . contain ) { const dims = getDimensions ( elem ) ; const elemWidth = dims . elem . width / scale ; const elemHeight = dims . elem . height / scale ; if ( elemWidth > 1 && elemHeight > 1 ) { const parentWidth = dims . parent . width - dims . parent . border . left - dims . parent . border . right ; const parentHeight = dims . parent . height - dims . parent . border . top - dims . parent . border . bottom ; const elemScaledWidth = parentWidth / elemWidth ; const elemScaledHeight = parentHeight / elemHeight ; if ( options . contain === 'inside' ) { maxScale = Math . min ( maxScale , elemScaledWidth , elemScaledHeight ) ; } else if ( options . contain === 'outside' ) { minScale = Math . max ( minScale , elemScaledWidth , elemScaledHeight ) ; } } } result . scale = Math . min ( Math . max ( toScale , minScale ) , maxScale ) ; return result ; } function pan ( toX , toY , panOptions ? , originalEvent ? ) { const result = constrainXY ( toX , toY , scale , panOptions ) ; if ( x !== result . x || y !== result . y ) { x = result . x ; y = result . y ; return setTransformWithEvent ( 'panzoompan' , result . opts , originalEvent ) ; } return { x , y , scale , isSVG , originalEvent } ; } function zoom ( toScale , zoomOptions ? , originalEvent ? ) { const result = constrainScale ( toScale , zoomOptions ) ; const opts = result . opts ; if ( ! opts . force && opts . disableZoom ) { return ; } toScale = result . scale ; let toX = x ; let toY = y ; if ( opts . focal ) { const focal = opts . focal ; toX = ( focal . x / toScale - focal . x / scale + x * toScale ) / toScale ; toY = ( focal . y / toScale - focal . y / scale + y * toScale ) / toScale ; } const panResult = constrainXY ( toX , toY , toScale , { relative : false , force : true } ) ; x = panResult . x ; y = panResult . y ; scale = toScale ; return setTransformWithEvent ( 'panzoomzoom' , opts , originalEvent ) ; } function zoomInOut ( isIn , zoomOptions ? ) { const opts = { ... options , animate : true , ... zoomOptions } ; return zoom ( scale * Math . exp ( ( isIn ? 1 : - 1 ) * opts . step ) , opts ) ; } function zoomIn ( zoomOptions ? ) { return zoomInOut ( true , zoomOptions ) ; } function zoomOut ( zoomOptions ? ) { return zoomInOut ( false , zoomOptions ) ; } function zoomToPoint ( toScale , point , zoomOptions ? , originalEvent ? ) { const dims = getDimensions ( elem ) ; const effectiveArea = { width : dims . parent . width - dims . parent . padding . left - dims . parent . padding . right - dims . parent . border . left - dims . parent . border . right , height : dims . parent . height - dims . parent . padding . top - dims . parent . padding . bottom - dims . parent . border . top - dims . parent . border . bottom } ; let clientX = point . clientX - dims . parent . left - dims . parent . padding . left - dims . parent . border . left - dims . elem . margin . left ; let clientY = point . clientY - dims . parent . top - dims . parent . padding . top - dims . parent . border . top - dims . elem . margin . top ; if ( ! isSVG ) { clientX -= dims . elem . width / scale / 2 ; clientY -= dims . elem . height / scale / 2 ; } const focal = { x : ( clientX / effectiveArea . width ) * ( effectiveArea . width * toScale ) , y : ( clientY / effectiveArea . height ) * ( effectiveArea . height * toScale ) } ; return zoom ( toScale , { animate : false , ... zoomOptions , focal } , originalEvent ) ; } function zoomWithWheel ( event , zoomOptions ? ) { event . preventDefault ( ) ; const opts = { ... options , ... zoomOptions , animate : false } ; const delta = event . deltaY === 0 && event . deltaX ? event . deltaX : event . deltaY ; const wheel = delta < 0 ? 1 : - 1 ; const toScale = constrainScale ( scale * Math . exp ( ( wheel * opts . step ) / 3 ) , opts ) . scale ; return zoomToPoint ( toScale , event , opts ) ; } function reset ( resetOptions ? ) { const opts = { ... options , animate : true , force : true , ... resetOptions } ; scale = constrainScale ( opts . startScale , opts ) . scale ; const panResult = constrainXY ( opts . startX , opts . startY , scale , opts ) ; x = panResult . x ; y = panResult . y ; return setTransformWithEvent ( 'panzoomreset' , opts ) ; } let origX ; let origY ; let startClientX ; let startClientY ; let startScale ; let startDistance ; const pointers = [ ] ; function handleDown ( event ) { if ( isExcluded ( event . target as Element , options ) ) { return ; } addPointer ( pointers , event ) ; isPanning = true ; options . handleStartEvent ( event ) ; origX = x ; origY = y ; trigger ( 'panzoomstart' , { x , y , scale , isSVG , originalEvent : event } , options ) ; const point = getMiddle ( pointers ) ; startClientX = point . clientX ; startClientY = point . clientY ; startScale = scale ; startDistance = getDistance ( pointers ) ; } function move ( event ) { if ( ! isPanning || origX === undefined || origY === undefined || startClientX === undefined || startClientY === undefined ) { return ; } addPointer ( pointers , event ) ; const current = getMiddle ( pointers ) ; if ( pointers . length > 1 ) { if ( startDistance === 0 ) { startDistance = getDistance ( pointers ) ; } const diff = getDistance ( pointers ) - startDistance ; const toScale = constrainScale ( ( diff * options . step ) / 80 + startScale ) . scale ; zoomToPoint ( toScale , current ) ; } else { pan ( origX + ( current . clientX - startClientX ) / scale , origY + ( current . clientY - startClientY ) / scale , { animate : false } , event ) ; } } function handleUp ( event ) { if ( pointers . length === 1 ) { trigger ( 'panzoomend' , { x , y , scale , isSVG , originalEvent : event } , options ) ; } removePointer ( pointers , event ) ; if ( ! isPanning ) { return ; } isPanning = false ; origX = origY = startClientX = startClientY = undefined ; } let bound = false ; function bind ( ) { if ( bound ) { return ; } bound = true ; onPointer ( 'down' , options . canvas ? parent : elem , handleDown ) ; onPointer ( 'move' , document , move , { passive : true } ) ; onPointer ( 'up' , document , handleUp , { passive : true } ) ; } function destroy ( ) { bound = false ; destroyPointer ( 'down' , options . canvas ? parent : elem , handleDown ) ; destroyPointer ( 'move' , document , move ) ; destroyPointer ( 'up' , document , handleUp ) ; } if ( ! options . noBind ) { bind ( ) ; } return { bind , destroy , eventNames , getPan : ( ) => ( { x , y } ) , getScale : ( ) => scale , getOptions : ( ) => shallowClone ( options ) , pan , reset , resetStyle , setOptions , setStyle : ( name , value ) => setStyle ( elem , name , value ) , zoom , zoomIn , zoomOut , zoomToPoint , zoomWithWheel } ; } Panzoom . defaultOptions = defaultOptions ; export { PanzoomObject , PanzoomOptions } ; export default Panzoom ;