@zoom-image/core
Version:
A core implementation of zoom image
475 lines (396 loc) • 15.4 kB
text/typescript
import { createStore } from "@namnode/store"
import type { PointerPosition } from "./utils"
import { clamp, computeZoomGesture, disableScroll, enableScroll, getSourceImage, makeMaybeCallFunction } from "./utils"
export type ZoomImageWheelOptions = {
maxZoom?: number
wheelZoomRatio?: number
dblTapAnimationDuration?: number
initialState?: Partial<ZoomImageWheelStateUpdate>
shouldZoomOnSingleTouch?: () => boolean
}
/* The delta values are not consistent across browsers.
* We need to normalize them to a consistent value.
* https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent/deltaY
*/
const ZOOM_DELTA = 0.5
export type ZoomImageWheelState = {
currentRotation: number
currentZoom: number
enable: boolean
currentPositionX: number
currentPositionY: number
}
export type ZoomImageWheelStateUpdate = Partial<{
enable: boolean
currentZoom: number
currentRotation: number
}>
const defaultInitialState: ZoomImageWheelState = {
currentZoom: 1,
enable: true,
currentPositionX: 0,
currentPositionY: 0,
currentRotation: 0,
}
const defaultShouldZoomOnSingleTouch = () => true
export function createZoomImageWheel(container: HTMLElement, options: ZoomImageWheelOptions = {}) {
const sourceImgElement = getSourceImage(container)
const finalOptions: Required<ZoomImageWheelOptions> = {
maxZoom: options.maxZoom || 4,
wheelZoomRatio: options.wheelZoomRatio || 0.1,
dblTapAnimationDuration: options.dblTapAnimationDuration || 300,
initialState: { ...defaultInitialState, ...options.initialState },
shouldZoomOnSingleTouch: options.shouldZoomOnSingleTouch || defaultShouldZoomOnSingleTouch,
}
const store = createStore<ZoomImageWheelState>(finalOptions.initialState as ZoomImageWheelState)
const checkDimensionSwitched = () => {
return [90, 270].includes(store.getState().currentRotation % 360)
}
const calculatePositionX = (newPositionX: number, currentZoom: number) => {
if (newPositionX > 0) return 0
const width = container.clientWidth
if (newPositionX + width * currentZoom < width) return -width * (currentZoom - 1)
return newPositionX
}
const calculatePositionY = (newPositionY: number, currentZoom: number) => {
if (newPositionY > 0) return 0
const height = container.clientHeight
if (newPositionY + height * currentZoom < height) return -height * (currentZoom - 1)
return newPositionY
}
const updateStateOnNewZoom = (currentZoom: number) => {
const zoomPointX = container.clientWidth / 2
const zoomPointY = container.clientHeight / 2
const isDimensionSwitched = checkDimensionSwitched()
const currentState = store.getState()
const zoomX = isDimensionSwitched ? currentState.currentPositionY : currentState.currentPositionX
const zoomY = isDimensionSwitched ? currentState.currentPositionX : currentState.currentPositionY
const zoomTargetX = (zoomPointX - zoomX) / currentState.currentZoom
const zoomTargetY = (zoomPointY - zoomY) / currentState.currentZoom
store.setState({
currentZoom,
currentPositionX: calculatePositionX(-zoomTargetX * currentZoom + zoomPointX, currentZoom),
currentPositionY: calculatePositionY(-zoomTargetY * currentZoom + zoomPointY, currentZoom),
})
}
// last pair of coordinates of a touch with two fingers
let prevTwoPositions: [PointerPosition, PointerPosition] | null = null
let enabledScroll = true
const pointerMap = new Map<number, PointerPosition>()
let lastPositionX = 0
let lastPositionY = 0
let startX = 0
let startY = 0
container.style.overflow = "hidden"
sourceImgElement.style.transformOrigin = "0 0"
function updateZoom() {
const currentState = store.getState()
sourceImgElement.style.transform = `translate(${currentState.currentPositionX}px, ${currentState.currentPositionY}px) scale(${currentState.currentZoom})`
container.style.rotate = `${currentState.currentRotation}deg`
}
function setState(newState: ZoomImageWheelStateUpdate) {
store.batch(() => {
const currentState = store.getState()
if (typeof newState.enable === "boolean" && newState.enable !== currentState.enable) {
store.setState({
enable: newState.enable,
})
if (!newState.enable) {
return
}
}
if (typeof newState.currentRotation === "number") {
const newCurrentRotation = newState.currentRotation
store.setState({
currentRotation: newCurrentRotation,
})
}
if (typeof newState.currentZoom === "number" && newState.currentZoom !== currentState.currentZoom) {
const newCurrentZoom = clamp(newState.currentZoom, 1, finalOptions.maxZoom)
if (newCurrentZoom === currentState.currentZoom) {
return
}
updateStateOnNewZoom(newCurrentZoom)
}
})
updateZoom()
}
function processZoomWheel({ delta, x, y }: { delta: number; x: number; y: number }) {
const containerRect = container.getBoundingClientRect()
const currentState = store.getState()
const isDimensionSwitched = checkDimensionSwitched()
let zoomPointX = -1
let zoomPointY = -1
switch (currentState.currentRotation % 360) {
case 0:
zoomPointX = x - containerRect.left
zoomPointY = y - containerRect.top
break
case 90:
zoomPointX = Math.abs(x - containerRect.right)
zoomPointY = Math.abs(y - containerRect.top)
break
case 180:
zoomPointX = Math.abs(x - containerRect.right)
zoomPointY = Math.abs(y - containerRect.bottom)
break
case 270:
zoomPointX = Math.abs(x - containerRect.left)
zoomPointY = Math.abs(y - containerRect.bottom)
break
}
const zoomX = isDimensionSwitched ? currentState.currentPositionY : currentState.currentPositionX
const zoomY = isDimensionSwitched ? currentState.currentPositionX : currentState.currentPositionY
const zoomTargetX = (zoomPointX - zoomX) / currentState.currentZoom
const zoomTargetY = (zoomPointY - zoomY) / currentState.currentZoom
const newCurrentZoom = clamp(
currentState.currentZoom + delta * finalOptions.wheelZoomRatio * currentState.currentZoom,
1,
finalOptions.maxZoom,
)
const newX = calculatePositionX(-zoomTargetX * newCurrentZoom + zoomPointX, newCurrentZoom)
const newY = calculatePositionY(-zoomTargetY * newCurrentZoom + zoomPointY, newCurrentZoom)
store.setState({
currentZoom: newCurrentZoom,
currentPositionX: isDimensionSwitched ? newY : newX,
currentPositionY: isDimensionSwitched ? newX : newY,
})
}
function updatePositionsForSinglePointerFlow() {
if (pointerMap.size === 1) {
const { x, y } = pointerMap.values().next().value as PointerPosition
const isDimensionSwitched = checkDimensionSwitched()
startX = isDimensionSwitched ? y : x
startY = isDimensionSwitched ? x : y
}
const currentState = store.getState()
lastPositionX = currentState.currentPositionX
lastPositionY = currentState.currentPositionY
}
function _handleWheel(event: WheelEvent) {
event.preventDefault()
if (store.getState().currentZoom === finalOptions.maxZoom && event.deltaY < 0) {
return
}
const delta = -clamp(event.deltaY, -ZOOM_DELTA, ZOOM_DELTA)
processZoomWheel({ delta, x: event.clientX, y: event.clientY })
updateZoom()
updatePositionsForSinglePointerFlow()
}
function _handlePointerMove(event: PointerEvent) {
event.preventDefault()
const { clientX, clientY, pointerId } = event
for (const [cachedPointerId] of pointerMap.entries()) {
if (cachedPointerId === pointerId) {
pointerMap.set(cachedPointerId, { x: clientX, y: clientY })
}
}
const { currentZoom, currentRotation } = store.getState()
if (pointerMap.size === 1 && currentZoom !== 1) {
const isDimensionSwitched = checkDimensionSwitched()
const normalizedClientX = isDimensionSwitched ? clientY : clientX
const normalizedClientY = isDimensionSwitched ? clientX : clientY
let offsetX = -1
let offsetY = -1
switch (currentRotation % 360) {
case 0:
offsetX = normalizedClientX - startX
offsetY = normalizedClientY - startY
break
case 90:
offsetX = normalizedClientX - startX
offsetY = startY - normalizedClientY
break
case 180:
offsetX = startX - normalizedClientX
offsetY = startY - normalizedClientY
break
case 270:
offsetX = startX - normalizedClientX
offsetY = normalizedClientY - startY
break
}
store.setState({
currentPositionX: calculatePositionX(lastPositionX + offsetX, currentZoom),
currentPositionY: calculatePositionY(lastPositionY + offsetY, currentZoom),
})
updateZoom()
}
}
const animationState = {
startTimestamp: null as DOMHighResTimeStamp | null,
// the state at the start of the zoom animation
start: { x: 0, y: 0, zoom: 0 },
// the target state at the end of the zoom animation
target: { x: 0, y: 0, zoom: 0 },
}
function animateZoom(touchCoordinate: { x: number; y: number }) {
// the `touchCoordinate` should be relative to the container
const currentState = store.getState()
animationState.startTimestamp = null
animationState.start = {
x: currentState.currentPositionX,
y: currentState.currentPositionY,
zoom: currentState.currentZoom,
}
if (currentState.currentZoom > 1) {
animationState.target = {
x: 0,
y: 0,
zoom: 1,
}
} else {
animationState.target = {
zoom: finalOptions.maxZoom,
x: touchCoordinate.x * (1 - finalOptions.maxZoom),
y: touchCoordinate.y * (1 - finalOptions.maxZoom),
}
}
function lerp(a: number, b: number, t: number): number {
return a * (1 - t) + b * t
}
function frame(timestamp: DOMHighResTimeStamp) {
if (animationState.startTimestamp === null) {
animationState.startTimestamp = timestamp
}
// interpolation parameter that linearly goes from 0 to 1 during the animation
let t = (timestamp - animationState.startTimestamp) / finalOptions.dblTapAnimationDuration
if (t > 1) {
t = 1
}
store.setState({
currentPositionX: lerp(animationState.start.x, animationState.target.x, t),
currentPositionY: lerp(animationState.start.y, animationState.target.y, t),
currentZoom: lerp(animationState.start.zoom, animationState.target.zoom, t),
})
updateZoom()
if (t < 1) {
requestAnimationFrame(frame)
}
}
requestAnimationFrame(frame)
}
// These variables are used for zooming on double tap
let touchTimer: NodeJS.Timeout | null = null
const durationBetweenTap = 300
function _handleTouchStart(event: TouchEvent) {
if (event.touches.length > 1) {
return
}
if (touchTimer === null) {
touchTimer = setTimeout(() => {
touchTimer = null
}, durationBetweenTap)
} else {
clearTimeout(touchTimer)
touchTimer = null
const rect = container.getBoundingClientRect()
const touch = event.touches[0]
animateZoom({
x: touch.clientX - rect.left,
y: touch.clientY - rect.top,
})
return
}
}
function _handleTouchMove(event: TouchEvent) {
if (finalOptions.shouldZoomOnSingleTouch()) event.preventDefault()
if (event.touches.length > 1) {
event.preventDefault()
const currentTwoPositions = [...event.touches].map((t) => ({ x: t.clientX, y: t.clientY })) as [
PointerPosition,
PointerPosition,
]
if (prevTwoPositions !== null) {
const { scale, center } = computeZoomGesture(prevTwoPositions, currentTwoPositions)
processZoomWheel({ delta: Math.log(scale) / finalOptions.wheelZoomRatio, ...center })
}
// Store the current two pointer positions for the next move event
prevTwoPositions = currentTwoPositions
updateZoom()
return
}
}
function _handlePointerDown(event: PointerEvent) {
if (event.pointerType === "touch" && !finalOptions.shouldZoomOnSingleTouch()) return
event.preventDefault()
if (pointerMap.size === 2) {
return
}
if (enabledScroll) {
disableScroll()
enabledScroll = false
}
const { clientX, clientY, pointerId } = event
const currentState = store.getState()
lastPositionX = currentState.currentPositionX
lastPositionY = currentState.currentPositionY
const isDimensionSwitched = checkDimensionSwitched()
startX = isDimensionSwitched ? clientY : clientX
startY = isDimensionSwitched ? clientX : clientY
pointerMap.set(pointerId, { x: clientX, y: clientY })
}
function _handlePointerUp(event: PointerEvent) {
event.preventDefault()
pointerMap.delete(event.pointerId)
// Reset the distance as soon as one of the pointers is released
if (pointerMap.size < 2) {
prevTwoPositions = null
}
if (pointerMap.size === 0 && !enabledScroll) {
enableScroll()
enabledScroll = true
}
updatePositionsForSinglePointerFlow()
}
function _handlePointerLeave(event: PointerEvent) {
event.preventDefault()
pointerMap.delete(event.pointerId)
prevTwoPositions = null
if (!enabledScroll) {
enableScroll()
enabledScroll = true
}
}
function checkZoomEnabled() {
return store.getState().enable
}
const handleWheel = makeMaybeCallFunction(checkZoomEnabled, _handleWheel)
const handlePointerDown = makeMaybeCallFunction(checkZoomEnabled, _handlePointerDown)
const handlePointerLeave = makeMaybeCallFunction(checkZoomEnabled, _handlePointerLeave)
const handlePointerMove = makeMaybeCallFunction(checkZoomEnabled, _handlePointerMove)
const handlePointerUp = makeMaybeCallFunction(checkZoomEnabled, _handlePointerUp)
const handleTouchStart = makeMaybeCallFunction(checkZoomEnabled, _handleTouchStart)
const handleTouchMove = makeMaybeCallFunction(checkZoomEnabled, _handleTouchMove)
const controller = new AbortController()
const { signal } = controller
container.addEventListener("wheel", handleWheel, { signal })
container.addEventListener("touchstart", handleTouchStart, { signal })
container.addEventListener("touchmove", handleTouchMove, { signal })
container.addEventListener("pointerdown", handlePointerDown, { signal })
container.addEventListener("pointerleave", handlePointerLeave, { signal })
container.addEventListener("pointermove", handlePointerMove, { signal })
container.addEventListener("pointerup", handlePointerUp, { signal })
container.addEventListener(
"touchend",
() => {
enabledScroll = true
enableScroll()
},
{ signal },
)
// Kick things off in case we have initial zoom other than 1
if (store.getState().currentZoom !== defaultInitialState.currentZoom) {
updateStateOnNewZoom(store.getState().currentZoom)
updateZoom()
}
return {
cleanup() {
controller.abort()
store.cleanup()
},
subscribe: store.subscribe,
setState,
getState: store.getState,
}
}