Compare commits

...

2 Commits

Author SHA1 Message Date
869a5309fb Update TemplatePreview Component Styles for Improved Responsiveness
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit
- Adjusted button and input styles in TemplatePreview to enhance layout adaptability.
- Changed button width to a minimum of 70px and input width to a range of 120px to 140px, improving user interaction and visual consistency.
2026-09-13 18:38:27 +01:00
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
6 changed files with 572 additions and 38 deletions

View File

@ -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);

View File

@ -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}
/>

View File

@ -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
}

View File

@ -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,29 +618,37 @@ 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}
/>
<Button
readOnly={true}
style={{ width: '65px' }}
style={{ minWidth: '70px' }}
disabled={loading || reloadLoading}
onClick={() => {
setPreviewScale(1)
@ -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}
/>
@ -631,7 +672,7 @@ const TemplatePreview = ({
value={previewWidth}
disabled={loading || reloadLoading}
onChange={(value) => setPreviewWidth(value || defaultPreviewWidth)}
style={{ width: 130 }}
style={{ minWidth: 120, maxWidth: '140px' }}
aria-label='Email preview width'
/>
) : null}
@ -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={() => {

View 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

View 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