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.
This commit is contained in:
parent
d63d925d2c
commit
92789b2d38
@ -2470,6 +2470,28 @@ body.objectKanbanColumnResizing * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.previewZoomOverlay {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.previewZoomCard {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--layout-header-bg) 80%,
|
||||
transparent
|
||||
);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.ant-slider .ant-slider-handle.previewZoomSliderHandle::after {
|
||||
box-shadow: 0 0 0 2px var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
}
|
||||
|
||||
.react-pdf__Page {
|
||||
border: 1px solid #000;
|
||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
|
||||
@ -6,6 +6,7 @@ import MinusIcon from '../../Icons/MinusIcon.jsx'
|
||||
import PanIcon from '../../Icons/PanIcon.jsx'
|
||||
import PanFilledIcon from '../../Icons/PanFilledIcon.jsx'
|
||||
import PDFViewer from './PDFViewer.jsx'
|
||||
import { clampPreviewScale } from '../hooks/usePinchZoom.js'
|
||||
|
||||
const PDFPreview = ({ file, loading = false, style }) => {
|
||||
const [previewScale, setPreviewScale] = useState(1)
|
||||
@ -30,14 +31,14 @@ const PDFPreview = ({ file, loading = false, style }) => {
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
onClick={() => {
|
||||
setPreviewScale((prev) => prev + 0.05)
|
||||
setPreviewScale((prev) => clampPreviewScale(prev + 0.05))
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
icon={<MinusIcon />}
|
||||
onClick={() => {
|
||||
setPreviewScale((prev) => Math.max(0.1, prev - 0.05))
|
||||
setPreviewScale((prev) => clampPreviewScale(prev - 0.05))
|
||||
}}
|
||||
disabled={loading}
|
||||
/>
|
||||
@ -63,6 +64,7 @@ const PDFPreview = ({ file, loading = false, style }) => {
|
||||
<PDFViewer
|
||||
file={file}
|
||||
scale={previewScale}
|
||||
onScaleChange={setPreviewScale}
|
||||
panMode={panMode}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Document, Page, pdfjs } from 'react-pdf'
|
||||
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||
@ -6,6 +12,8 @@ import 'react-pdf/dist/Page/AnnotationLayer.css'
|
||||
import 'react-pdf/dist/Page/TextLayer.css'
|
||||
import LoadingPlaceholder from './LoadingPlaceholder.jsx'
|
||||
import ScrollBox from './ScrollBox.jsx'
|
||||
import usePreserveZoomScroll from '../hooks/usePreserveZoomScroll.js'
|
||||
import usePinchZoom from '../hooks/usePinchZoom.js'
|
||||
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorker
|
||||
|
||||
@ -16,10 +24,14 @@ const PDFViewer = ({
|
||||
scale = 1,
|
||||
panMode = false,
|
||||
loading = false,
|
||||
onScaleChange,
|
||||
onLoadSuccess = noop,
|
||||
onLoadError = noop
|
||||
}) => {
|
||||
const containerRef = useRef(null)
|
||||
const scrollElementRef = useRef(null)
|
||||
const zoomContentRef = useRef(null)
|
||||
const pdfPagesRef = useRef(null)
|
||||
const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 })
|
||||
const pdfDocumentRef = useRef(null)
|
||||
const prevFileRef = useRef(file)
|
||||
@ -27,6 +39,8 @@ const PDFViewer = ({
|
||||
const [numPages, setNumPages] = useState(0)
|
||||
const [isPanning, setIsPanning] = useState(false)
|
||||
const [hasLoadError, setHasLoadError] = useState(false)
|
||||
const [pdfSize, setPdfSize] = useState({ width: 0, height: 0 })
|
||||
const [renderedScale, setRenderedScale] = useState(scale)
|
||||
|
||||
const activeFile = loading == true ? null : file
|
||||
if (prevFileRef.current !== activeFile) {
|
||||
@ -65,11 +79,48 @@ const PDFViewer = ({
|
||||
useEffect(() => {
|
||||
setNumPages(0)
|
||||
setHasLoadError(false)
|
||||
setPdfSize({ width: 0, height: 0 })
|
||||
return () => {
|
||||
destroyPdfDocument()
|
||||
}
|
||||
}, [activeFile, destroyPdfDocument])
|
||||
|
||||
useEffect(() => {
|
||||
if (scale === renderedScale) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
setRenderedScale(scale)
|
||||
}, 500)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [renderedScale, scale])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const pages = pdfPagesRef.current
|
||||
if (!pages || typeof ResizeObserver !== 'function') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const updatePdfSize = () => {
|
||||
const width = pages.offsetWidth / renderedScale
|
||||
const height = pages.offsetHeight / renderedScale
|
||||
if (width <= 0 && height <= 0) {
|
||||
return
|
||||
}
|
||||
setPdfSize((prev) =>
|
||||
prev.width === width && prev.height === height
|
||||
? prev
|
||||
: { width, height }
|
||||
)
|
||||
}
|
||||
|
||||
updatePdfSize()
|
||||
const observer = new ResizeObserver(updatePdfSize)
|
||||
observer.observe(pages)
|
||||
return () => observer.disconnect()
|
||||
}, [activeFile, numPages, renderedScale])
|
||||
|
||||
useEffect(() => {
|
||||
if (!panMode) {
|
||||
setIsPanning(false)
|
||||
@ -138,12 +189,31 @@ const PDFViewer = ({
|
||||
}
|
||||
}, [isPanning])
|
||||
|
||||
const { captureAtClientPoint } = usePreserveZoomScroll(
|
||||
scrollElementRef,
|
||||
zoomContentRef,
|
||||
scale,
|
||||
`${documentKeyRef.current}:${numPages}`
|
||||
)
|
||||
|
||||
usePinchZoom({
|
||||
containerRef,
|
||||
scale,
|
||||
onScaleChange,
|
||||
onBeforeScaleChange: captureAtClientPoint,
|
||||
enabled: typeof onScaleChange === 'function' && loading != true
|
||||
})
|
||||
|
||||
const isDocumentLoading =
|
||||
Boolean(activeFile) && numPages === 0 && hasLoadError != true
|
||||
const showLoadingPlaceholder = loading == true || isDocumentLoading
|
||||
const scaledPdfWidth = pdfSize.width * scale
|
||||
const scaledPdfHeight = pdfSize.height * scale
|
||||
const liveTransformScale = scale / renderedScale
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
@ -194,25 +264,39 @@ const PDFViewer = ({
|
||||
onLoadError={handleDocumentLoadError}
|
||||
>
|
||||
<div
|
||||
ref={zoomContentRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
gap: 32,
|
||||
width: 'max-content',
|
||||
minWidth: '100%'
|
||||
width: scaledPdfWidth || undefined,
|
||||
height: scaledPdfHeight || undefined,
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
{Array.from(new Array(numPages), (_el, index) => (
|
||||
<Page
|
||||
key={`page_${index + 1}`}
|
||||
pageNumber={index + 1}
|
||||
scale={scale}
|
||||
renderTextLayer={panMode != true}
|
||||
renderAnnotationLayer={panMode != true}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
ref={pdfPagesRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
gap: 32 * renderedScale,
|
||||
width: 'max-content',
|
||||
transform:
|
||||
liveTransformScale === 1
|
||||
? undefined
|
||||
: `scale(${liveTransformScale})`,
|
||||
transformOrigin: 'top left'
|
||||
}}
|
||||
>
|
||||
{Array.from(new Array(numPages), (_el, index) => (
|
||||
<Page
|
||||
key={`page_${index + 1}`}
|
||||
pageNumber={index + 1}
|
||||
scale={renderedScale}
|
||||
renderTextLayer={panMode != true}
|
||||
renderAnnotationLayer={panMode != true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Document>
|
||||
</div>
|
||||
@ -232,6 +316,7 @@ PDFViewer.propTypes = {
|
||||
scale: PropTypes.number,
|
||||
panMode: PropTypes.bool,
|
||||
loading: PropTypes.bool,
|
||||
onScaleChange: PropTypes.func,
|
||||
onLoadSuccess: PropTypes.func,
|
||||
onLoadError: PropTypes.func
|
||||
}
|
||||
|
||||
@ -19,6 +19,14 @@ import ScrollBox from './ScrollBox.jsx'
|
||||
import { ApiServerContext } from '../context/ApiServerContext.jsx'
|
||||
import ProgressDisplay from './ProgressDisplay.jsx'
|
||||
import { CaretDownFilled } from '@ant-design/icons'
|
||||
import { Slider } from 'antd'
|
||||
import usePreserveZoomScroll from '../hooks/usePreserveZoomScroll.js'
|
||||
import usePinchZoom, {
|
||||
clampPreviewScale,
|
||||
MAX_PREVIEW_SCALE,
|
||||
MIN_PREVIEW_SCALE
|
||||
} from '../hooks/usePinchZoom.js'
|
||||
import KeyboardShortcut from './KeyboardShortcut.jsx'
|
||||
const noop = () => {}
|
||||
|
||||
const scaleCssSize = (size, scale) => {
|
||||
@ -52,6 +60,7 @@ const TemplatePreview = ({
|
||||
const defaultPreviewWidth = capabilities.defaultWidth || 600
|
||||
const iframeRef = useRef(null)
|
||||
const scrollElementRef = useRef(null)
|
||||
const zoomContentRef = useRef(null)
|
||||
const savedScrollRef = useRef({ top: 0, left: 0 })
|
||||
const iframeSizeObserverRef = useRef(null)
|
||||
const previewRequestIdRef = useRef(0)
|
||||
@ -79,6 +88,7 @@ const TemplatePreview = ({
|
||||
})
|
||||
|
||||
const toolbarRef = useRef(null)
|
||||
const previewPaneRef = useRef(null)
|
||||
const [usePopoverSettings, setUsePopoverSettings] = useState(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@ -106,7 +116,11 @@ const TemplatePreview = ({
|
||||
return
|
||||
}
|
||||
|
||||
if (showWidthControl && !doc.querySelector('.previewDocument') && doc.body) {
|
||||
if (
|
||||
showWidthControl &&
|
||||
!doc.querySelector('.previewDocument') &&
|
||||
doc.body
|
||||
) {
|
||||
const container = doc.createElement('div')
|
||||
container.className = 'previewContainer'
|
||||
const pages = doc.createElement('div')
|
||||
@ -234,6 +248,25 @@ const TemplatePreview = ({
|
||||
restoreScrollPosition()
|
||||
}, [iframeSize, restoreScrollPosition])
|
||||
|
||||
const { captureAtClientPoint } = usePreserveZoomScroll(
|
||||
scrollElementRef,
|
||||
zoomContentRef,
|
||||
previewScale,
|
||||
previewType == 'HTML'
|
||||
? `${iframeSize.width}x${iframeSize.height}`
|
||||
: 'pdf-inactive'
|
||||
)
|
||||
|
||||
usePinchZoom({
|
||||
containerRef: previewPaneRef,
|
||||
iframeRef,
|
||||
iframeBindKey: previewContentHTML,
|
||||
scale: previewScale,
|
||||
onScaleChange: setPreviewScale,
|
||||
onBeforeScaleChange: captureAtClientPoint,
|
||||
enabled: previewType == 'HTML' && !loading && !reloadLoading
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (previewType != 'HTML') {
|
||||
return
|
||||
@ -585,23 +618,31 @@ const TemplatePreview = ({
|
||||
|
||||
const toolbarContent = (
|
||||
<>
|
||||
<Button
|
||||
icon={
|
||||
panMode ? (
|
||||
<PanFilledIcon style={{ color: 'var(--color-primary)' }} />
|
||||
) : (
|
||||
<PanIcon />
|
||||
)
|
||||
}
|
||||
disabled={loading || reloadLoading}
|
||||
onClick={() => {
|
||||
<KeyboardShortcut
|
||||
shortcut={'alt+p'}
|
||||
hint={'ALT P'}
|
||||
onTrigger={() => {
|
||||
setPanMode((prev) => !prev)
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Button
|
||||
icon={
|
||||
panMode ? (
|
||||
<PanFilledIcon style={{ color: 'var(--color-primary)' }} />
|
||||
) : (
|
||||
<PanIcon />
|
||||
)
|
||||
}
|
||||
disabled={loading || reloadLoading}
|
||||
onClick={() => {
|
||||
setPanMode((prev) => !prev)
|
||||
}}
|
||||
/>
|
||||
</KeyboardShortcut>
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
onClick={() => {
|
||||
setPreviewScale((prev) => prev + 0.05)
|
||||
setPreviewScale((prev) => clampPreviewScale(prev + 0.05))
|
||||
}}
|
||||
disabled={loading || reloadLoading}
|
||||
/>
|
||||
@ -618,7 +659,7 @@ const TemplatePreview = ({
|
||||
<Button
|
||||
icon={<MinusIcon />}
|
||||
onClick={() => {
|
||||
setPreviewScale((prev) => Math.max(0.1, prev - 0.05))
|
||||
setPreviewScale((prev) => clampPreviewScale(prev - 0.05))
|
||||
}}
|
||||
disabled={loading || reloadLoading}
|
||||
/>
|
||||
@ -641,7 +682,7 @@ const TemplatePreview = ({
|
||||
{ value: 'HTML', label: 'HTML' },
|
||||
{ value: 'PDF', label: 'PDF' }
|
||||
]}
|
||||
style
|
||||
style={{ minWidth: 79 }}
|
||||
loading={(loading || reloadLoading) && !usePopoverSettings}
|
||||
disabled={loading || reloadLoading}
|
||||
value={previewType}
|
||||
@ -704,6 +745,7 @@ const TemplatePreview = ({
|
||||
</Flex>
|
||||
|
||||
<div
|
||||
ref={previewPaneRef}
|
||||
style={{
|
||||
flex: '1 1 auto',
|
||||
minHeight: 0,
|
||||
@ -741,7 +783,31 @@ const TemplatePreview = ({
|
||||
</Flex>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className='previewZoomOverlay'>
|
||||
<Card
|
||||
style={{ borderRadius: 18 }}
|
||||
styles={{ body: { padding: '10px 17px', width: 200 } }}
|
||||
className='previewZoomCard'
|
||||
>
|
||||
<Slider
|
||||
min={MIN_PREVIEW_SCALE}
|
||||
max={MAX_PREVIEW_SCALE}
|
||||
step={0.05}
|
||||
tooltip={{ open: false }}
|
||||
value={previewScale}
|
||||
style={{ margin: 0 }}
|
||||
onChange={(value) => setPreviewScale(value)}
|
||||
classNames={{
|
||||
handle: 'previewZoomSliderHandle'
|
||||
}}
|
||||
styles={{
|
||||
track: {
|
||||
background: 'var(--color-primary)'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
{previewType == 'HTML' ? (
|
||||
<ScrollBox
|
||||
scrollableNodeProps={{ ref: scrollElementRef }}
|
||||
@ -760,6 +826,7 @@ const TemplatePreview = ({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={zoomContentRef}
|
||||
style={{
|
||||
width: scaledIframeWidth,
|
||||
height: scaledIframeHeight,
|
||||
@ -779,7 +846,8 @@ const TemplatePreview = ({
|
||||
style={{
|
||||
width:
|
||||
iframeSize.width === '100%' ? '100%' : iframeSize.width,
|
||||
height: iframeSize.height === 'auto' ? 150 : iframeSize.height,
|
||||
height:
|
||||
iframeSize.height === 'auto' ? 150 : iframeSize.height,
|
||||
display: 'block',
|
||||
border: 0,
|
||||
overflow: 'hidden',
|
||||
@ -795,6 +863,7 @@ const TemplatePreview = ({
|
||||
<PDFViewer
|
||||
file={pdfBlob}
|
||||
scale={previewScale}
|
||||
onScaleChange={setPreviewScale}
|
||||
panMode={panMode}
|
||||
loading={false}
|
||||
onLoadError={() => {
|
||||
|
||||
193
src/components/Dashboard/hooks/usePinchZoom.js
Normal file
193
src/components/Dashboard/hooks/usePinchZoom.js
Normal file
@ -0,0 +1,193 @@
|
||||
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
|
||||
163
src/components/Dashboard/hooks/usePreserveZoomScroll.js
Normal file
163
src/components/Dashboard/hooks/usePreserveZoomScroll.js
Normal file
@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
const captureZoomScrollMetrics = (scrollEl, contentEl, scale) => {
|
||||
if (!scrollEl || !contentEl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const contentRect = contentEl.getBoundingClientRect()
|
||||
if (contentRect.width <= 0 && contentRect.height <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect()
|
||||
return {
|
||||
scale,
|
||||
scrollLeft: scrollEl.scrollLeft,
|
||||
scrollTop: scrollEl.scrollTop,
|
||||
clientWidth: scrollEl.clientWidth,
|
||||
clientHeight: scrollEl.clientHeight,
|
||||
contentWidth: contentRect.width,
|
||||
contentHeight: contentRect.height,
|
||||
contentOffsetLeft: contentRect.left - scrollRect.left + scrollEl.scrollLeft,
|
||||
contentOffsetTop: contentRect.top - scrollRect.top + scrollEl.scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
const restoreZoomScrollOrigin = (scrollEl, contentEl, prev, nextScale) => {
|
||||
if (!scrollEl || !contentEl || !prev?.scale || prev.scale === nextScale) {
|
||||
return
|
||||
}
|
||||
|
||||
const ratio = nextScale / prev.scale
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const anchorX = Number.isFinite(prev.anchorX)
|
||||
? prev.anchorX
|
||||
: prev.clientWidth / 2
|
||||
const anchorY = Number.isFinite(prev.anchorY)
|
||||
? prev.anchorY
|
||||
: prev.clientHeight / 2
|
||||
const pointX = prev.scrollLeft + anchorX - prev.contentOffsetLeft
|
||||
const pointY = prev.scrollTop + anchorY - prev.contentOffsetTop
|
||||
|
||||
const scrollRect = scrollEl.getBoundingClientRect()
|
||||
const contentRect = contentEl.getBoundingClientRect()
|
||||
const contentOffsetLeft =
|
||||
contentRect.left - scrollRect.left + scrollEl.scrollLeft
|
||||
const contentOffsetTop =
|
||||
contentRect.top - scrollRect.top + scrollEl.scrollTop
|
||||
|
||||
scrollEl.scrollTo(
|
||||
contentOffsetLeft + pointX * ratio - anchorX,
|
||||
contentOffsetTop + pointY * ratio - anchorY
|
||||
)
|
||||
}
|
||||
|
||||
const usePreserveZoomScroll = (
|
||||
scrollElementRef,
|
||||
contentElementRef,
|
||||
scale,
|
||||
layoutKey
|
||||
) => {
|
||||
const metricsRef = useRef(null)
|
||||
const pendingRef = useRef(null)
|
||||
const scaleRef = useRef(scale)
|
||||
scaleRef.current = scale
|
||||
|
||||
const captureMetrics = useCallback(() => {
|
||||
if (pendingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const metrics = captureZoomScrollMetrics(
|
||||
scrollElementRef.current,
|
||||
contentElementRef.current,
|
||||
scaleRef.current
|
||||
)
|
||||
if (metrics) {
|
||||
metricsRef.current = metrics
|
||||
}
|
||||
}, [contentElementRef, scrollElementRef])
|
||||
|
||||
const captureAtClientPoint = useCallback(
|
||||
(clientX, clientY) => {
|
||||
const scrollEl = scrollElementRef.current
|
||||
const metrics = captureZoomScrollMetrics(
|
||||
scrollEl,
|
||||
contentElementRef.current,
|
||||
scaleRef.current
|
||||
)
|
||||
if (!metrics || !scrollEl) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = scrollEl.getBoundingClientRect()
|
||||
metrics.anchorX = clientX - rect.left
|
||||
metrics.anchorY = clientY - rect.top
|
||||
metricsRef.current = metrics
|
||||
},
|
||||
[contentElementRef, scrollElementRef]
|
||||
)
|
||||
|
||||
const tryRestore = useCallback(() => {
|
||||
const pending = pendingRef.current
|
||||
const scrollEl = scrollElementRef.current
|
||||
const contentEl = contentElementRef.current
|
||||
if (!pending || !scrollEl || !contentEl) {
|
||||
captureMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
const contentRect = contentEl.getBoundingClientRect()
|
||||
const sizeChanged =
|
||||
Math.abs(contentRect.width - pending.prev.contentWidth) > 0.5 ||
|
||||
Math.abs(contentRect.height - pending.prev.contentHeight) > 0.5
|
||||
if (!sizeChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
restoreZoomScrollOrigin(scrollEl, contentEl, pending.prev, pending.nextScale)
|
||||
pendingRef.current = null
|
||||
captureMetrics()
|
||||
}, [captureMetrics, contentElementRef, scrollElementRef])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const prev = metricsRef.current
|
||||
if (prev && prev.scale !== scale) {
|
||||
pendingRef.current = { prev, nextScale: scale }
|
||||
}
|
||||
tryRestore()
|
||||
}, [layoutKey, scale, tryRestore])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const contentEl = contentElementRef.current
|
||||
if (!contentEl || typeof ResizeObserver !== 'function') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
tryRestore()
|
||||
})
|
||||
observer.observe(contentEl)
|
||||
return () => observer.disconnect()
|
||||
}, [contentElementRef, layoutKey, tryRestore])
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollElementRef.current
|
||||
if (!scrollEl) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
scrollEl.addEventListener('scroll', captureMetrics, { passive: true })
|
||||
return () => {
|
||||
scrollEl.removeEventListener('scroll', captureMetrics)
|
||||
}
|
||||
}, [captureMetrics, layoutKey, scrollElementRef])
|
||||
|
||||
return { captureAtClientPoint }
|
||||
}
|
||||
|
||||
export default usePreserveZoomScroll
|
||||
Loading…
x
Reference in New Issue
Block a user