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;
|
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 {
|
.react-pdf__Page {
|
||||||
border: 1px solid #000;
|
border: 1px solid #000;
|
||||||
box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
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 PanIcon from '../../Icons/PanIcon.jsx'
|
||||||
import PanFilledIcon from '../../Icons/PanFilledIcon.jsx'
|
import PanFilledIcon from '../../Icons/PanFilledIcon.jsx'
|
||||||
import PDFViewer from './PDFViewer.jsx'
|
import PDFViewer from './PDFViewer.jsx'
|
||||||
|
import { clampPreviewScale } from '../hooks/usePinchZoom.js'
|
||||||
|
|
||||||
const PDFPreview = ({ file, loading = false, style }) => {
|
const PDFPreview = ({ file, loading = false, style }) => {
|
||||||
const [previewScale, setPreviewScale] = useState(1)
|
const [previewScale, setPreviewScale] = useState(1)
|
||||||
@ -30,14 +31,14 @@ const PDFPreview = ({ file, loading = false, style }) => {
|
|||||||
<Button
|
<Button
|
||||||
icon={<PlusIcon />}
|
icon={<PlusIcon />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreviewScale((prev) => prev + 0.05)
|
setPreviewScale((prev) => clampPreviewScale(prev + 0.05))
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<MinusIcon />}
|
icon={<MinusIcon />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreviewScale((prev) => Math.max(0.1, prev - 0.05))
|
setPreviewScale((prev) => clampPreviewScale(prev - 0.05))
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@ -63,6 +64,7 @@ const PDFPreview = ({ file, loading = false, style }) => {
|
|||||||
<PDFViewer
|
<PDFViewer
|
||||||
file={file}
|
file={file}
|
||||||
scale={previewScale}
|
scale={previewScale}
|
||||||
|
onScaleChange={setPreviewScale}
|
||||||
panMode={panMode}
|
panMode={panMode}
|
||||||
loading={loading}
|
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 PropTypes from 'prop-types'
|
||||||
import { Document, Page, pdfjs } from 'react-pdf'
|
import { Document, Page, pdfjs } from 'react-pdf'
|
||||||
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
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 'react-pdf/dist/Page/TextLayer.css'
|
||||||
import LoadingPlaceholder from './LoadingPlaceholder.jsx'
|
import LoadingPlaceholder from './LoadingPlaceholder.jsx'
|
||||||
import ScrollBox from './ScrollBox.jsx'
|
import ScrollBox from './ScrollBox.jsx'
|
||||||
|
import usePreserveZoomScroll from '../hooks/usePreserveZoomScroll.js'
|
||||||
|
import usePinchZoom from '../hooks/usePinchZoom.js'
|
||||||
|
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorker
|
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorker
|
||||||
|
|
||||||
@ -16,10 +24,14 @@ const PDFViewer = ({
|
|||||||
scale = 1,
|
scale = 1,
|
||||||
panMode = false,
|
panMode = false,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
onScaleChange,
|
||||||
onLoadSuccess = noop,
|
onLoadSuccess = noop,
|
||||||
onLoadError = noop
|
onLoadError = noop
|
||||||
}) => {
|
}) => {
|
||||||
|
const containerRef = useRef(null)
|
||||||
const scrollElementRef = 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 panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 })
|
||||||
const pdfDocumentRef = useRef(null)
|
const pdfDocumentRef = useRef(null)
|
||||||
const prevFileRef = useRef(file)
|
const prevFileRef = useRef(file)
|
||||||
@ -27,6 +39,8 @@ const PDFViewer = ({
|
|||||||
const [numPages, setNumPages] = useState(0)
|
const [numPages, setNumPages] = useState(0)
|
||||||
const [isPanning, setIsPanning] = useState(false)
|
const [isPanning, setIsPanning] = useState(false)
|
||||||
const [hasLoadError, setHasLoadError] = 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
|
const activeFile = loading == true ? null : file
|
||||||
if (prevFileRef.current !== activeFile) {
|
if (prevFileRef.current !== activeFile) {
|
||||||
@ -65,11 +79,48 @@ const PDFViewer = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNumPages(0)
|
setNumPages(0)
|
||||||
setHasLoadError(false)
|
setHasLoadError(false)
|
||||||
|
setPdfSize({ width: 0, height: 0 })
|
||||||
return () => {
|
return () => {
|
||||||
destroyPdfDocument()
|
destroyPdfDocument()
|
||||||
}
|
}
|
||||||
}, [activeFile, 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(() => {
|
useEffect(() => {
|
||||||
if (!panMode) {
|
if (!panMode) {
|
||||||
setIsPanning(false)
|
setIsPanning(false)
|
||||||
@ -138,12 +189,31 @@ const PDFViewer = ({
|
|||||||
}
|
}
|
||||||
}, [isPanning])
|
}, [isPanning])
|
||||||
|
|
||||||
|
const { captureAtClientPoint } = usePreserveZoomScroll(
|
||||||
|
scrollElementRef,
|
||||||
|
zoomContentRef,
|
||||||
|
scale,
|
||||||
|
`${documentKeyRef.current}:${numPages}`
|
||||||
|
)
|
||||||
|
|
||||||
|
usePinchZoom({
|
||||||
|
containerRef,
|
||||||
|
scale,
|
||||||
|
onScaleChange,
|
||||||
|
onBeforeScaleChange: captureAtClientPoint,
|
||||||
|
enabled: typeof onScaleChange === 'function' && loading != true
|
||||||
|
})
|
||||||
|
|
||||||
const isDocumentLoading =
|
const isDocumentLoading =
|
||||||
Boolean(activeFile) && numPages === 0 && hasLoadError != true
|
Boolean(activeFile) && numPages === 0 && hasLoadError != true
|
||||||
const showLoadingPlaceholder = loading == true || isDocumentLoading
|
const showLoadingPlaceholder = loading == true || isDocumentLoading
|
||||||
|
const scaledPdfWidth = pdfSize.width * scale
|
||||||
|
const scaledPdfHeight = pdfSize.height * scale
|
||||||
|
const liveTransformScale = scale / renderedScale
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={containerRef}
|
||||||
style={{
|
style={{
|
||||||
height: '100%',
|
height: '100%',
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@ -194,25 +264,39 @@ const PDFViewer = ({
|
|||||||
onLoadError={handleDocumentLoadError}
|
onLoadError={handleDocumentLoadError}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={zoomContentRef}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
width: scaledPdfWidth || undefined,
|
||||||
flexDirection: 'row',
|
height: scaledPdfHeight || undefined,
|
||||||
alignItems: 'flex-start',
|
margin: '0 auto'
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 32,
|
|
||||||
width: 'max-content',
|
|
||||||
minWidth: '100%'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Array.from(new Array(numPages), (_el, index) => (
|
<div
|
||||||
<Page
|
ref={pdfPagesRef}
|
||||||
key={`page_${index + 1}`}
|
style={{
|
||||||
pageNumber={index + 1}
|
display: 'flex',
|
||||||
scale={scale}
|
flexDirection: 'row',
|
||||||
renderTextLayer={panMode != true}
|
alignItems: 'flex-start',
|
||||||
renderAnnotationLayer={panMode != true}
|
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>
|
</div>
|
||||||
</Document>
|
</Document>
|
||||||
</div>
|
</div>
|
||||||
@ -232,6 +316,7 @@ PDFViewer.propTypes = {
|
|||||||
scale: PropTypes.number,
|
scale: PropTypes.number,
|
||||||
panMode: PropTypes.bool,
|
panMode: PropTypes.bool,
|
||||||
loading: PropTypes.bool,
|
loading: PropTypes.bool,
|
||||||
|
onScaleChange: PropTypes.func,
|
||||||
onLoadSuccess: PropTypes.func,
|
onLoadSuccess: PropTypes.func,
|
||||||
onLoadError: PropTypes.func
|
onLoadError: PropTypes.func
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,14 @@ import ScrollBox from './ScrollBox.jsx'
|
|||||||
import { ApiServerContext } from '../context/ApiServerContext.jsx'
|
import { ApiServerContext } from '../context/ApiServerContext.jsx'
|
||||||
import ProgressDisplay from './ProgressDisplay.jsx'
|
import ProgressDisplay from './ProgressDisplay.jsx'
|
||||||
import { CaretDownFilled } from '@ant-design/icons'
|
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 noop = () => {}
|
||||||
|
|
||||||
const scaleCssSize = (size, scale) => {
|
const scaleCssSize = (size, scale) => {
|
||||||
@ -52,6 +60,7 @@ const TemplatePreview = ({
|
|||||||
const defaultPreviewWidth = capabilities.defaultWidth || 600
|
const defaultPreviewWidth = capabilities.defaultWidth || 600
|
||||||
const iframeRef = useRef(null)
|
const iframeRef = useRef(null)
|
||||||
const scrollElementRef = useRef(null)
|
const scrollElementRef = useRef(null)
|
||||||
|
const zoomContentRef = useRef(null)
|
||||||
const savedScrollRef = useRef({ top: 0, left: 0 })
|
const savedScrollRef = useRef({ top: 0, left: 0 })
|
||||||
const iframeSizeObserverRef = useRef(null)
|
const iframeSizeObserverRef = useRef(null)
|
||||||
const previewRequestIdRef = useRef(0)
|
const previewRequestIdRef = useRef(0)
|
||||||
@ -79,6 +88,7 @@ const TemplatePreview = ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const toolbarRef = useRef(null)
|
const toolbarRef = useRef(null)
|
||||||
|
const previewPaneRef = useRef(null)
|
||||||
const [usePopoverSettings, setUsePopoverSettings] = useState(false)
|
const [usePopoverSettings, setUsePopoverSettings] = useState(false)
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@ -106,7 +116,11 @@ const TemplatePreview = ({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showWidthControl && !doc.querySelector('.previewDocument') && doc.body) {
|
if (
|
||||||
|
showWidthControl &&
|
||||||
|
!doc.querySelector('.previewDocument') &&
|
||||||
|
doc.body
|
||||||
|
) {
|
||||||
const container = doc.createElement('div')
|
const container = doc.createElement('div')
|
||||||
container.className = 'previewContainer'
|
container.className = 'previewContainer'
|
||||||
const pages = doc.createElement('div')
|
const pages = doc.createElement('div')
|
||||||
@ -234,6 +248,25 @@ const TemplatePreview = ({
|
|||||||
restoreScrollPosition()
|
restoreScrollPosition()
|
||||||
}, [iframeSize, 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(() => {
|
useLayoutEffect(() => {
|
||||||
if (previewType != 'HTML') {
|
if (previewType != 'HTML') {
|
||||||
return
|
return
|
||||||
@ -585,23 +618,31 @@ const TemplatePreview = ({
|
|||||||
|
|
||||||
const toolbarContent = (
|
const toolbarContent = (
|
||||||
<>
|
<>
|
||||||
<Button
|
<KeyboardShortcut
|
||||||
icon={
|
shortcut={'alt+p'}
|
||||||
panMode ? (
|
hint={'ALT P'}
|
||||||
<PanFilledIcon style={{ color: 'var(--color-primary)' }} />
|
onTrigger={() => {
|
||||||
) : (
|
|
||||||
<PanIcon />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={loading || reloadLoading}
|
|
||||||
onClick={() => {
|
|
||||||
setPanMode((prev) => !prev)
|
setPanMode((prev) => !prev)
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
<Button
|
||||||
|
icon={
|
||||||
|
panMode ? (
|
||||||
|
<PanFilledIcon style={{ color: 'var(--color-primary)' }} />
|
||||||
|
) : (
|
||||||
|
<PanIcon />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
disabled={loading || reloadLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setPanMode((prev) => !prev)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</KeyboardShortcut>
|
||||||
<Button
|
<Button
|
||||||
icon={<PlusIcon />}
|
icon={<PlusIcon />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreviewScale((prev) => prev + 0.05)
|
setPreviewScale((prev) => clampPreviewScale(prev + 0.05))
|
||||||
}}
|
}}
|
||||||
disabled={loading || reloadLoading}
|
disabled={loading || reloadLoading}
|
||||||
/>
|
/>
|
||||||
@ -618,7 +659,7 @@ const TemplatePreview = ({
|
|||||||
<Button
|
<Button
|
||||||
icon={<MinusIcon />}
|
icon={<MinusIcon />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPreviewScale((prev) => Math.max(0.1, prev - 0.05))
|
setPreviewScale((prev) => clampPreviewScale(prev - 0.05))
|
||||||
}}
|
}}
|
||||||
disabled={loading || reloadLoading}
|
disabled={loading || reloadLoading}
|
||||||
/>
|
/>
|
||||||
@ -641,7 +682,7 @@ const TemplatePreview = ({
|
|||||||
{ value: 'HTML', label: 'HTML' },
|
{ value: 'HTML', label: 'HTML' },
|
||||||
{ value: 'PDF', label: 'PDF' }
|
{ value: 'PDF', label: 'PDF' }
|
||||||
]}
|
]}
|
||||||
style
|
style={{ minWidth: 79 }}
|
||||||
loading={(loading || reloadLoading) && !usePopoverSettings}
|
loading={(loading || reloadLoading) && !usePopoverSettings}
|
||||||
disabled={loading || reloadLoading}
|
disabled={loading || reloadLoading}
|
||||||
value={previewType}
|
value={previewType}
|
||||||
@ -704,6 +745,7 @@ const TemplatePreview = ({
|
|||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
ref={previewPaneRef}
|
||||||
style={{
|
style={{
|
||||||
flex: '1 1 auto',
|
flex: '1 1 auto',
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@ -741,7 +783,31 @@ const TemplatePreview = ({
|
|||||||
</Flex>
|
</Flex>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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' ? (
|
{previewType == 'HTML' ? (
|
||||||
<ScrollBox
|
<ScrollBox
|
||||||
scrollableNodeProps={{ ref: scrollElementRef }}
|
scrollableNodeProps={{ ref: scrollElementRef }}
|
||||||
@ -760,6 +826,7 @@ const TemplatePreview = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
ref={zoomContentRef}
|
||||||
style={{
|
style={{
|
||||||
width: scaledIframeWidth,
|
width: scaledIframeWidth,
|
||||||
height: scaledIframeHeight,
|
height: scaledIframeHeight,
|
||||||
@ -779,7 +846,8 @@ const TemplatePreview = ({
|
|||||||
style={{
|
style={{
|
||||||
width:
|
width:
|
||||||
iframeSize.width === '100%' ? '100%' : iframeSize.width,
|
iframeSize.width === '100%' ? '100%' : iframeSize.width,
|
||||||
height: iframeSize.height === 'auto' ? 150 : iframeSize.height,
|
height:
|
||||||
|
iframeSize.height === 'auto' ? 150 : iframeSize.height,
|
||||||
display: 'block',
|
display: 'block',
|
||||||
border: 0,
|
border: 0,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@ -795,6 +863,7 @@ const TemplatePreview = ({
|
|||||||
<PDFViewer
|
<PDFViewer
|
||||||
file={pdfBlob}
|
file={pdfBlob}
|
||||||
scale={previewScale}
|
scale={previewScale}
|
||||||
|
onScaleChange={setPreviewScale}
|
||||||
panMode={panMode}
|
panMode={panMode}
|
||||||
loading={false}
|
loading={false}
|
||||||
onLoadError={() => {
|
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