diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css
index 2290b9ae..d0e7d61d 100644
--- a/assets/stylesheets/App.css
+++ b/assets/stylesheets/App.css
@@ -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);
diff --git a/src/components/Dashboard/common/PDFPreview.jsx b/src/components/Dashboard/common/PDFPreview.jsx
index 78fceab0..a1b7ceb9 100644
--- a/src/components/Dashboard/common/PDFPreview.jsx
+++ b/src/components/Dashboard/common/PDFPreview.jsx
@@ -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 }) => {
}
onClick={() => {
- setPreviewScale((prev) => prev + 0.05)
+ setPreviewScale((prev) => clampPreviewScale(prev + 0.05))
}}
disabled={loading}
/>
}
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 }) => {
diff --git a/src/components/Dashboard/common/PDFViewer.jsx b/src/components/Dashboard/common/PDFViewer.jsx
index f496d416..74dba829 100644
--- a/src/components/Dashboard/common/PDFViewer.jsx
+++ b/src/components/Dashboard/common/PDFViewer.jsx
@@ -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 (
- {Array.from(new Array(numPages), (_el, index) => (
-
- ))}
+
+ {Array.from(new Array(numPages), (_el, index) => (
+
+ ))}
+
@@ -232,6 +316,7 @@ PDFViewer.propTypes = {
scale: PropTypes.number,
panMode: PropTypes.bool,
loading: PropTypes.bool,
+ onScaleChange: PropTypes.func,
onLoadSuccess: PropTypes.func,
onLoadError: PropTypes.func
}
diff --git a/src/components/Dashboard/common/TemplatePreview.jsx b/src/components/Dashboard/common/TemplatePreview.jsx
index 251e530b..b8e65f30 100644
--- a/src/components/Dashboard/common/TemplatePreview.jsx
+++ b/src/components/Dashboard/common/TemplatePreview.jsx
@@ -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 = (
<>
-
- ) : (
-
- )
- }
- disabled={loading || reloadLoading}
- onClick={() => {
+ {
setPanMode((prev) => !prev)
}}
- />
+ >
+
+ ) : (
+
+ )
+ }
+ disabled={loading || reloadLoading}
+ onClick={() => {
+ setPanMode((prev) => !prev)
+ }}
+ />
+
}
onClick={() => {
- setPreviewScale((prev) => prev + 0.05)
+ setPreviewScale((prev) => clampPreviewScale(prev + 0.05))
}}
disabled={loading || reloadLoading}
/>
@@ -618,7 +659,7 @@ const TemplatePreview = ({
}
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 = ({
) : null}
-
+
+
+ setPreviewScale(value)}
+ classNames={{
+ handle: 'previewZoomSliderHandle'
+ }}
+ styles={{
+ track: {
+ background: 'var(--color-primary)'
+ }
+ }}
+ />
+
+
{previewType == 'HTML' ? (
{
diff --git a/src/components/Dashboard/hooks/usePinchZoom.js b/src/components/Dashboard/hooks/usePinchZoom.js
new file mode 100644
index 00000000..ad2f06a5
--- /dev/null
+++ b/src/components/Dashboard/hooks/usePinchZoom.js
@@ -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
diff --git a/src/components/Dashboard/hooks/usePreserveZoomScroll.js b/src/components/Dashboard/hooks/usePreserveZoomScroll.js
new file mode 100644
index 00000000..25dd1c8d
--- /dev/null
+++ b/src/components/Dashboard/hooks/usePreserveZoomScroll.js
@@ -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