Tom Butcher 53d488011b Add Pan Icon and Pan Filled Icon Components for Template Preview
- Introduced new PanIcon and PanFilledIcon components to enhance the TemplatePreview functionality.
- Implemented panning feature in TemplatePreview, allowing users to navigate through content more intuitively.
- Updated TemplatePreview to toggle panning mode with visual feedback using the new icons.
- Enhanced user experience by managing pointer events and cursor styles during panning.
2026-08-21 23:15:41 +01:00

468 lines
13 KiB
JavaScript

import {
useState,
useContext,
useEffect,
useLayoutEffect,
useRef,
useCallback
} from 'react'
import PropTypes from 'prop-types'
import { Flex, Button, Input, Select } from 'antd'
import PlusIcon from '../../Icons/PlusIcon.jsx'
import MinusIcon from '../../Icons/MinusIcon.jsx'
import InfoCircleIcon from '../../Icons/InfoCircleIcon.jsx'
import PanIcon from '../../Icons/PanIcon.jsx'
import PanFilledIcon from '../../Icons/PanFilledIcon.jsx'
import ObjectProperty from '../common/ObjectProperty.jsx'
import ScrollBox from './ScrollBox.jsx'
import { ApiServerContext } from '../context/ApiServerContext.jsx'
const noop = () => {}
const TemplatePreview = ({
objectData,
documentTemplate,
loading,
isEditing,
onTestObjectOpen = noop,
onPreviewMessage = noop,
showTestObject = false,
showPreviewSwitch = true
}) => {
const iframeRef = useRef(null)
const scrollElementRef = useRef(null)
const savedScrollRef = useRef({ top: 0, left: 0 })
const iframeSizeObserverRef = useRef(null)
const previewRequestIdRef = useRef(0)
const { fetchTemplatePreview, fetchTemplatePDF } =
useContext(ApiServerContext)
const [previewContentHTML, setPreviewContentHTML] = useState('')
const [pdfBlob, setPDFBlob] = useState(null)
const [reloadLoading, setReloadLoading] = useState(false)
const [previewScale, setPreviewScale] = useState(1)
const [previewType, setPreviewType] = useState('HTML')
const [panMode, setPanMode] = useState(false)
const [isPanning, setIsPanning] = useState(false)
const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 })
const [iframeSize, setIframeSize] = useState({
width: '100%',
height: 'auto'
})
const applyIframeContentSize = useCallback(() => {
const iframe = iframeRef.current
const doc = iframe?.contentDocument
if (!iframe || !doc?.documentElement) {
return
}
const documentEl =
doc.querySelector('.previewDocument') ||
doc.querySelector('.previewWrapper') ||
doc.body
const container = doc.querySelector('.previewContainer')
const wrapper = doc.querySelector('.previewWrapper')
const visual = documentEl.getBoundingClientRect()
if (visual.width <= 0 && visual.height <= 0) {
return
}
if (wrapper) {
wrapper.style.width = `${visual.width}px`
wrapper.style.height = `${visual.height}px`
}
let padX = 0
let padY = 0
if (container) {
const containerStyle = doc.defaultView.getComputedStyle(container)
padX =
parseFloat(containerStyle.paddingLeft) +
parseFloat(containerStyle.paddingRight)
padY =
parseFloat(containerStyle.paddingTop) +
parseFloat(containerStyle.paddingBottom)
}
const contentWidth = Math.ceil(visual.width + padX)
const contentHeight = Math.ceil(visual.height + padY)
if (!doc.head?.querySelector('style[data-preview-outer-scroll]')) {
const styleEl = doc.createElement('style')
styleEl.setAttribute('data-preview-outer-scroll', 'true')
styleEl.textContent =
'html, body { overflow: hidden !important; height: auto !important; }'
doc.head?.appendChild(styleEl)
}
setIframeSize((prev) => {
const width = `${contentWidth}px`
const height = `${contentHeight}px`
if (prev.width === width && prev.height === height) {
return prev
}
return { width, height }
})
}, [])
const restoreScrollPosition = useCallback(() => {
const scrollEl = scrollElementRef.current
if (!scrollEl) {
return
}
scrollEl.scrollTo(savedScrollRef.current.left, savedScrollRef.current.top)
}, [])
const handleIframeLoad = useCallback(() => {
iframeSizeObserverRef.current?.disconnect()
iframeSizeObserverRef.current = null
if (previewType != 'HTML') {
return
}
applyIframeContentSize()
const doc = iframeRef.current?.contentDocument
const observed =
doc?.querySelector('.previewDocument') ||
doc?.querySelector('.previewContainer') ||
doc?.body
if (observed && typeof ResizeObserver === 'function') {
const observer = new ResizeObserver(() => {
applyIframeContentSize()
})
observer.observe(observed)
iframeSizeObserverRef.current = observer
}
}, [applyIframeContentSize, previewType])
useLayoutEffect(() => {
restoreScrollPosition()
}, [iframeSize, restoreScrollPosition])
const updatePreviewContentHTML = (html) => {
const scrollEl = scrollElementRef.current
if (scrollEl) {
savedScrollRef.current = {
top: scrollEl.scrollTop,
left: scrollEl.scrollLeft
}
}
setPreviewContentHTML(html)
}
const reloadPreviewPDF = useCallback(
(content, testObject = {}) => {
const templateId = documentTemplate?._id
if (!templateId) {
return
}
const requestId = ++previewRequestIdRef.current
setReloadLoading(true)
fetchTemplatePDF(templateId, content, testObject, (result) => {
if (requestId !== previewRequestIdRef.current) {
return
}
setReloadLoading(false)
if (result?.error) {
onPreviewMessage(result.error, true)
} else {
const pdfBlob = new Blob([result.pdf], { type: 'application/pdf' })
const pdfUrl = URL.createObjectURL(pdfBlob)
setPDFBlob(pdfUrl)
onPreviewMessage('No issues found.', false)
}
})
},
[documentTemplate?._id, fetchTemplatePDF, onPreviewMessage]
)
const reloadPreview = useCallback(
(content, testObject = {}, scale = 1) => {
const templateId = documentTemplate?._id
if (!templateId) {
return
}
const requestId = ++previewRequestIdRef.current
setReloadLoading(true)
fetchTemplatePreview(templateId, content, testObject, scale, (result) => {
if (requestId !== previewRequestIdRef.current) {
return
}
setReloadLoading(false)
if (result?.error) {
onPreviewMessage(result.error, true)
} else {
updatePreviewContentHTML(result.html)
onPreviewMessage('No issues found.', false)
}
})
},
[fetchTemplatePreview, onPreviewMessage, documentTemplate?._id]
)
const templateContent = documentTemplate?.content
const templateId = documentTemplate?._id
// Move useEffect to component level and use state to track objectData changes
useEffect(() => {
if (!templateId || !templateContent) {
return
}
const timeoutId = window.setTimeout(() => {
if (previewType == 'HTML') {
reloadPreview(templateContent, objectData, previewScale)
} else {
reloadPreviewPDF(templateContent, objectData)
}
}, 300)
return () => {
window.clearTimeout(timeoutId)
}
}, [
objectData,
templateContent,
templateId,
previewScale,
previewType,
reloadPreview,
reloadPreviewPDF
])
useEffect(() => {
return () => {
iframeSizeObserverRef.current?.disconnect()
}
}, [])
useEffect(() => {
if (!panMode) {
setIsPanning(false)
}
}, [panMode])
useEffect(() => {
const scrollEl = scrollElementRef.current
if (!panMode || !scrollEl || previewType != 'HTML') {
return
}
const handlePointerDown = (event) => {
if (event.pointerType === 'mouse' && event.button !== 0) {
return
}
event.preventDefault()
panStartRef.current = {
x: event.clientX,
y: event.clientY,
scrollLeft: scrollEl.scrollLeft,
scrollTop: scrollEl.scrollTop
}
setIsPanning(true)
}
scrollEl.addEventListener('pointerdown', handlePointerDown)
return () => {
scrollEl.removeEventListener('pointerdown', handlePointerDown)
}
}, [panMode, previewType])
useEffect(() => {
if (!isPanning) {
return
}
const previousCursor = document.body.style.cursor
const previousUserSelect = document.body.style.userSelect
document.body.style.cursor = 'grabbing'
document.body.style.userSelect = 'none'
const handlePointerMove = (event) => {
const scrollEl = scrollElementRef.current
if (!scrollEl) {
return
}
const { x, y, scrollLeft, scrollTop } = panStartRef.current
scrollEl.scrollLeft = scrollLeft - (event.clientX - x)
scrollEl.scrollTop = scrollTop - (event.clientY - y)
}
const handlePointerUp = () => {
setIsPanning(false)
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
window.addEventListener('pointercancel', handlePointerUp)
return () => {
document.body.style.cursor = previousCursor
document.body.style.userSelect = previousUserSelect
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
window.removeEventListener('pointercancel', handlePointerUp)
}
}, [isPanning])
return (
<Flex vertical gap={'middle'} style={{ height: '100%' }}>
<Flex gap={'small'}>
{showTestObject == true ? (
<>
{documentTemplate?.objectType ? (
<ObjectProperty
objectType={documentTemplate?.objectType}
name={'testObject'}
isEditing={true}
objectData={objectData}
disabled={!isEditing || objectData?.global}
type={'object'}
style={{ minWidth: 0 }}
/>
) : (
<div style={{ flexGrow: 1 }}>
<Input disabled={true} />
</div>
)}
<Button
icon={<InfoCircleIcon />}
disabled={objectData?.global}
onClick={() => {
onTestObjectOpen()
}}
/>
</>
) : null}
<Button
icon={
panMode ? (
<PanFilledIcon style={{ color: 'var(--color-primary)' }} />
) : (
<PanIcon />
)
}
disabled={loading || reloadLoading || previewType == 'PDF'}
onClick={() => {
setPanMode((prev) => !prev)
}}
/>
<Button
icon={<PlusIcon />}
onClick={() => {
setPreviewScale((prev) => prev + 0.05)
}}
disabled={loading || reloadLoading || previewType == 'PDF'}
/>
<Button
icon={<MinusIcon />}
onClick={() => {
setPreviewScale((prev) => prev - 0.05)
}}
disabled={loading || reloadLoading || previewType == 'PDF'}
/>
<Button
readOnly={true}
style={{ width: '65px' }}
disabled={loading || reloadLoading || previewType == 'PDF'}
onClick={() => {
setPreviewScale(1)
}}
>
{previewScale.toFixed(2)}x
</Button>
{showPreviewSwitch == true ? (
<Select
options={[
{ value: 'HTML', label: 'HTML' },
{ value: 'PDF', label: 'PDF' }
]}
loading={loading || reloadLoading}
disabled={loading || reloadLoading}
value={previewType}
onChange={(value) => {
setPreviewType(value)
}}
/>
) : null}
</Flex>
<div
style={{
flex: '1 1 auto',
minHeight: 0,
border: '1px solid #85858541'
}}
>
{previewType == 'HTML' ? (
<ScrollBox
scrollableNodeProps={{ ref: scrollElementRef }}
style={{
cursor: panMode ? (isPanning ? 'grabbing' : 'grab') : undefined,
touchAction: panMode ? 'none' : undefined
}}
>
<div
style={{
width: iframeSize.width,
height: iframeSize.height,
cursor: panMode
? isPanning
? 'grabbing'
: 'grab'
: undefined
}}
>
<iframe
ref={iframeRef}
srcDoc={previewContentHTML}
onLoad={handleIframeLoad}
scrolling='no'
frameBorder='0'
style={{
width: '100%',
height: iframeSize.height === 'auto' ? 150 : '100%',
display: 'block',
border: 0,
overflow: 'hidden',
pointerEvents: panMode ? 'none' : undefined
}}
/>
</div>
</ScrollBox>
) : (
<iframe
ref={iframeRef}
src={pdfBlob}
frameBorder='0'
style={{
width: '100%',
height: '100%',
border: 0
}}
/>
)}
</div>
</Flex>
)
}
TemplatePreview.propTypes = {
loading: PropTypes.bool,
objectData: PropTypes.object,
documentTemplate: PropTypes.object,
isEditing: PropTypes.bool,
style: PropTypes.object,
showTestObject: PropTypes.bool,
onTestObjectOpen: PropTypes.func,
onPreviewMessage: PropTypes.func,
showPreviewSwitch: PropTypes.bool
}
export default TemplatePreview