Tom Butcher 92789b2d38 Implement Pinch Zoom and Scroll Preservation for PDF and Template Previews
- Added usePinchZoom and usePreserveZoomScroll hooks to enable pinch zoom functionality and maintain scroll position during zooming in PDFViewer and TemplatePreview components.
- Introduced a new clampPreviewScale function to ensure zoom levels remain within defined limits.
- Enhanced TemplatePreview with a zoom slider and overlay for better user interaction.
- Updated PDFViewer to handle scale changes and improve rendering performance.
- Refactored related components to support the new zooming features, enhancing overall user experience.
2026-09-13 18:30:00 +01:00

194 lines
5.1 KiB
JavaScript

import { useCallback, useEffect, useRef } from 'react'
export const MIN_PREVIEW_SCALE = 0.1
export const MAX_PREVIEW_SCALE = 5
const isPinchZoomEvent = (event) => event.ctrlKey || event.metaKey
export const clampPreviewScale = (value) => {
if (!Number.isFinite(value)) {
return 1
}
const clamped = Math.min(MAX_PREVIEW_SCALE, Math.max(MIN_PREVIEW_SCALE, value))
return Math.round(clamped * 10000) / 10000
}
const nextScaleFromWheel = (scale, event) => {
const intensity = event.deltaMode === 1 ? 0.12 : 0.0045
return clampPreviewScale(scale * Math.exp(-event.deltaY * intensity))
}
const eventClientPoint = (event, iframeRef, scale) => {
const iframe = iframeRef?.current
const eventDoc = event.target?.ownerDocument
if (
iframe &&
eventDoc &&
eventDoc !== document &&
eventDoc === iframe.contentDocument
) {
const rect = iframe.getBoundingClientRect()
return {
clientX: rect.left + event.clientX * scale,
clientY: rect.top + event.clientY * scale
}
}
return { clientX: event.clientX, clientY: event.clientY }
}
const usePinchZoom = ({
containerRef,
iframeRef,
iframeBindKey,
scale = 1,
onScaleChange,
onBeforeScaleChange,
enabled = true
}) => {
const onScaleChangeRef = useRef(onScaleChange)
onScaleChangeRef.current = onScaleChange
const onBeforeScaleChangeRef = useRef(onBeforeScaleChange)
onBeforeScaleChangeRef.current = onBeforeScaleChange
const scaleRef = useRef(scale)
scaleRef.current = scale
const gestureStartScaleRef = useRef(scale)
const enabledRef = useRef(enabled)
enabledRef.current = enabled
const handleWheel = useCallback(
(event) => {
if (!enabledRef.current || !isPinchZoomEvent(event)) {
return
}
if (event.target?.closest?.('.previewZoomOverlay')) {
return
}
event.preventDefault()
event.stopPropagation()
const current = scaleRef.current
const next = nextScaleFromWheel(current, event)
if (next === current) {
return
}
const point = eventClientPoint(event, iframeRef, current)
onBeforeScaleChangeRef.current?.(point.clientX, point.clientY)
onScaleChangeRef.current?.(next)
},
[iframeRef]
)
const handleGestureStart = useCallback((event) => {
if (!enabledRef.current) {
return
}
event.preventDefault()
gestureStartScaleRef.current = scaleRef.current
}, [])
const handleGestureChange = useCallback(
(event) => {
if (!enabledRef.current) {
return
}
event.preventDefault()
const startScale = gestureStartScaleRef.current
if (!Number.isFinite(startScale)) {
return
}
const next = clampPreviewScale(startScale * event.scale)
if (next === scaleRef.current) {
return
}
const point = eventClientPoint(event, iframeRef, scaleRef.current)
onBeforeScaleChangeRef.current?.(point.clientX, point.clientY)
onScaleChangeRef.current?.(next)
},
[iframeRef]
)
useEffect(() => {
const container = containerRef?.current
if (!container || !enabled || typeof onScaleChange !== 'function') {
return undefined
}
const options = { capture: true, passive: false }
container.addEventListener('wheel', handleWheel, options)
container.addEventListener('gesturestart', handleGestureStart, options)
container.addEventListener('gesturechange', handleGestureChange, options)
return () => {
container.removeEventListener('wheel', handleWheel, options)
container.removeEventListener('gesturestart', handleGestureStart, options)
container.removeEventListener(
'gesturechange',
handleGestureChange,
options
)
}
}, [
containerRef,
enabled,
handleGestureChange,
handleGestureStart,
handleWheel,
onScaleChange
])
useEffect(() => {
const iframe = iframeRef?.current
if (!iframe || !enabled || typeof onScaleChange !== 'function') {
return undefined
}
const options = { capture: true, passive: false }
let boundDoc = null
const unbind = () => {
if (!boundDoc) {
return
}
boundDoc.removeEventListener('wheel', handleWheel, options)
boundDoc.removeEventListener('gesturestart', handleGestureStart, options)
boundDoc.removeEventListener(
'gesturechange',
handleGestureChange,
options
)
boundDoc = null
}
const bind = () => {
const doc = iframe.contentDocument
if (!doc || boundDoc === doc) {
return
}
unbind()
doc.addEventListener('wheel', handleWheel, options)
doc.addEventListener('gesturestart', handleGestureStart, options)
doc.addEventListener('gesturechange', handleGestureChange, options)
boundDoc = doc
}
iframe.addEventListener('load', bind)
bind()
return () => {
iframe.removeEventListener('load', bind)
unbind()
}
}, [
enabled,
handleGestureChange,
handleGestureStart,
handleWheel,
iframeBindKey,
iframeRef,
onScaleChange
])
}
export default usePinchZoom