Add PDF Preview and Viewer Components with Enhanced Styling
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit

- Introduced PDFPreview and PDFViewer components to facilitate PDF rendering and interaction within the dashboard.
- Implemented zoom and pan functionalities in PDFPreview for improved user experience.
- Enhanced App.css with styles for a new overlay during PDF loading, ensuring a smooth visual transition.
- Updated TemplatePreview to integrate PDF viewing capabilities, allowing users to preview PDF documents seamlessly.
- Modified ApiServerContext to support asynchronous PDF downloads, improving document handling efficiency.
This commit is contained in:
Tom Butcher 2026-08-22 12:55:50 +01:00
parent 6dcc5759f4
commit 85273bea1b
8 changed files with 885 additions and 59 deletions

View File

@ -1684,3 +1684,26 @@ span.ant-skeleton-input.ant-skeleton-input-sm.text-skeleton {
.code-block-editor .simplebar-content-wrapper {
background-color: var(--layout-modal-bg);
}
.previewProgressOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
backdrop-filter: blur(8px);
background-color: rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s ease;
}
.previewProgressOverlay.visible {
opacity: 1;
pointer-events: auto;
}
.previewProgressOverlay.hidden {
opacity: 0;
pointer-events: none;
}

View File

@ -65,11 +65,13 @@
"loglevel": "^1.9.2",
"nanoid": "^5.1.14",
"online-3d-viewer": "^0.16.0",
"pdfjs-dist": "5.4.296",
"prop-types": "^15.8.1",
"react": "^19.1.1",
"react-country-flag": "^3.1.0",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"react-pdf": "^10.5.0",
"react-responsive": "^10.0.1",
"react-router-dom": "^7.8.2",
"recharts": "^3.8.1",

186
pnpm-lock.yaml generated
View File

@ -184,6 +184,9 @@ importers:
online-3d-viewer:
specifier: ^0.16.0
version: 0.16.0
pdfjs-dist:
specifier: 5.4.296
version: 5.4.296
prop-types:
specifier: ^15.8.1
version: 15.8.1
@ -199,6 +202,9 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.10)(react@19.2.4)
react-pdf:
specifier: ^10.5.0
version: 10.5.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react-responsive:
specifier: ^10.0.1
version: 10.0.1(react@19.2.4)
@ -1448,6 +1454,76 @@ packages:
'@marijn/find-cluster-break@1.0.3':
resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==}
'@napi-rs/canvas-android-arm64@0.1.100':
resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@napi-rs/canvas-darwin-arm64@0.1.100':
resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@napi-rs/canvas-darwin-x64@0.1.100':
resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-linux-x64-musl@0.1.100':
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@napi-rs/canvas@0.1.100':
resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==}
engines: {node: '>= 10'}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@ -4434,6 +4510,12 @@ packages:
magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
make-cancellable-promise@2.0.0:
resolution: {integrity: sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==}
make-event-props@2.0.0:
resolution: {integrity: sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==}
make-fetch-happen@14.0.3:
resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==}
engines: {node: ^18.17.0 || >=20.5.0}
@ -4526,6 +4608,14 @@ packages:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
merge-refs@2.0.0:
resolution: {integrity: sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@ -5023,6 +5113,10 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdfjs-dist@5.4.296:
resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==}
engines: {node: '>=20.16.0 || >=22.3.0'}
pe-library@0.4.1:
resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==}
engines: {node: '>=12', npm: '>=6'}
@ -5515,6 +5609,16 @@ packages:
'@types/react': '>=18'
react: '>=18'
react-pdf@10.5.0:
resolution: {integrity: sha512-zGKb6Ph5AYn2OfO4TS8Gg0Go3eW5K/TKy0+0FTm8EwFNbpgbuaWBZoJdYkJt+cRBCkizE9dYvqtMtHRj522Q4w==}
peerDependencies:
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
@ -6472,6 +6576,9 @@ packages:
w3c-keyname@2.2.8:
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
@ -8017,6 +8124,54 @@ snapshots:
'@marijn/find-cluster-break@1.0.3': {}
'@napi-rs/canvas-android-arm64@0.1.100':
optional: true
'@napi-rs/canvas-darwin-arm64@0.1.100':
optional: true
'@napi-rs/canvas-darwin-x64@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
optional: true
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
optional: true
'@napi-rs/canvas-linux-x64-musl@0.1.100':
optional: true
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
optional: true
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
optional: true
'@napi-rs/canvas@0.1.100':
optionalDependencies:
'@napi-rs/canvas-android-arm64': 0.1.100
'@napi-rs/canvas-darwin-arm64': 0.1.100
'@napi-rs/canvas-darwin-x64': 0.1.100
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100
'@napi-rs/canvas-linux-arm64-gnu': 0.1.100
'@napi-rs/canvas-linux-arm64-musl': 0.1.100
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-gnu': 0.1.100
'@napi-rs/canvas-linux-x64-musl': 0.1.100
'@napi-rs/canvas-win32-arm64-msvc': 0.1.100
'@napi-rs/canvas-win32-x64-msvc': 0.1.100
optional: true
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -11575,6 +11730,10 @@ snapshots:
dependencies:
sourcemap-codec: 1.4.8
make-cancellable-promise@2.0.0: {}
make-event-props@2.0.0: {}
make-fetch-happen@14.0.3:
dependencies:
'@npmcli/agent': 3.0.0
@ -11780,6 +11939,10 @@ snapshots:
merge-descriptors@2.0.0: {}
merge-refs@2.0.0(@types/react@19.2.10):
optionalDependencies:
'@types/react': 19.2.10
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@ -12392,6 +12555,10 @@ snapshots:
pathe@2.0.3: {}
pdfjs-dist@5.4.296:
optionalDependencies:
'@napi-rs/canvas': 0.1.100
pe-library@0.4.1: {}
pend@1.2.0: {}
@ -13025,6 +13192,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
react-pdf@10.5.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
clsx: 2.1.1
dequal: 2.0.3
make-cancellable-promise: 2.0.0
make-event-props: 2.0.0
merge-refs: 2.0.0(@types/react@19.2.10)
pdfjs-dist: 5.4.296
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
tiny-invariant: 1.3.3
warning: 4.0.3
optionalDependencies:
'@types/react': 19.2.10
react-redux@9.3.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
@ -14212,6 +14394,10 @@ snapshots:
w3c-keyname@2.2.8: {}
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
wcwidth@1.0.1:
dependencies:
defaults: 1.0.4

View File

@ -0,0 +1,85 @@
import { useState } from 'react'
import PropTypes from 'prop-types'
import { Flex, Button } from 'antd'
import PlusIcon from '../../Icons/PlusIcon.jsx'
import MinusIcon from '../../Icons/MinusIcon.jsx'
import PanIcon from '../../Icons/PanIcon.jsx'
import PanFilledIcon from '../../Icons/PanFilledIcon.jsx'
import PDFViewer from './PDFViewer.jsx'
const PDFPreview = ({ file, loading = false, style }) => {
const [previewScale, setPreviewScale] = useState(1)
const [panMode, setPanMode] = useState(false)
return (
<Flex vertical gap={'middle'} style={{ height: '100%', ...style }}>
<Flex gap={'small'}>
<Button
icon={
panMode ? (
<PanFilledIcon style={{ color: 'var(--color-primary)' }} />
) : (
<PanIcon />
)
}
disabled={loading}
onClick={() => {
setPanMode((prev) => !prev)
}}
/>
<Button
icon={<PlusIcon />}
onClick={() => {
setPreviewScale((prev) => prev + 0.05)
}}
disabled={loading}
/>
<Button
icon={<MinusIcon />}
onClick={() => {
setPreviewScale((prev) => Math.max(0.1, prev - 0.05))
}}
disabled={loading}
/>
<Button
readOnly={true}
style={{ width: '65px' }}
disabled={loading}
onClick={() => {
setPreviewScale(1)
}}
>
{previewScale.toFixed(2)}x
</Button>
</Flex>
<div
style={{
flex: '1 1 auto',
minHeight: 0,
border: '1px solid #85858541'
}}
>
<PDFViewer
file={file}
scale={previewScale}
panMode={panMode}
loading={loading}
/>
</div>
</Flex>
)
}
PDFPreview.propTypes = {
file: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(Blob),
PropTypes.instanceOf(ArrayBuffer),
PropTypes.object
]),
loading: PropTypes.bool,
style: PropTypes.object
}
export default PDFPreview

View File

@ -0,0 +1,241 @@
import { useCallback, useEffect, 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'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
import LoadingPlaceholder from './LoadingPlaceholder.jsx'
import ScrollBox from './ScrollBox.jsx'
pdfjs.GlobalWorkerOptions.workerSrc = pdfWorker
const noop = () => {}
const PDFViewer = ({
file,
scale = 1,
panMode = false,
loading = false,
onLoadSuccess = noop,
onLoadError = noop
}) => {
const scrollElementRef = useRef(null)
const panStartRef = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 })
const pdfDocumentRef = useRef(null)
const prevFileRef = useRef(file)
const documentKeyRef = useRef(0)
const [numPages, setNumPages] = useState(0)
const [isPanning, setIsPanning] = useState(false)
const [hasLoadError, setHasLoadError] = useState(false)
const activeFile = loading == true ? null : file
if (prevFileRef.current !== activeFile) {
prevFileRef.current = activeFile
documentKeyRef.current += 1
}
const destroyPdfDocument = useCallback(() => {
const pdf = pdfDocumentRef.current
pdfDocumentRef.current = null
if (pdf && typeof pdf.destroy === 'function') {
pdf.destroy()
}
}, [])
const handleDocumentLoadSuccess = useCallback(
(pdf) => {
pdfDocumentRef.current = pdf
setHasLoadError(false)
setNumPages(pdf.numPages)
onLoadSuccess(pdf)
},
[onLoadSuccess]
)
const handleDocumentLoadError = useCallback(
(error) => {
destroyPdfDocument()
setHasLoadError(true)
setNumPages(0)
onLoadError(error)
},
[destroyPdfDocument, onLoadError]
)
useEffect(() => {
setNumPages(0)
setHasLoadError(false)
return () => {
destroyPdfDocument()
}
}, [activeFile, destroyPdfDocument])
useEffect(() => {
if (!panMode) {
setIsPanning(false)
}
}, [panMode])
useEffect(() => {
const scrollEl = scrollElementRef.current
if (!panMode || !scrollEl) {
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, activeFile])
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])
const isDocumentLoading =
Boolean(activeFile) && numPages === 0 && hasLoadError != true
const showLoadingPlaceholder = loading == true || isDocumentLoading
return (
<div
style={{
height: '100%',
minHeight: 0,
overflow: 'visible',
position: 'relative'
}}
>
{showLoadingPlaceholder ? (
<div
style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
...(activeFile
? { position: 'absolute', inset: 0, zIndex: 1 }
: {})
}}
>
<LoadingPlaceholder message={'Loading PDF...'} />
</div>
) : null}
{activeFile ? (
<ScrollBox
scrollableNodeProps={{ ref: scrollElementRef }}
style={{
cursor: panMode ? (isPanning ? 'grabbing' : 'grab') : undefined,
touchAction: panMode ? 'none' : undefined
}}
>
<div
style={{
padding: 60,
width: 'max-content',
minWidth: '100%',
cursor: panMode ? (isPanning ? 'grabbing' : 'grab') : undefined,
pointerEvents: panMode ? 'none' : undefined
}}
>
<Document
key={documentKeyRef.current}
file={activeFile}
loading={null}
error={
<div style={{ color: 'var(--color-error, #ff4d4f)' }}>
Failed to load PDF.
</div>
}
onLoadSuccess={handleDocumentLoadSuccess}
onLoadError={handleDocumentLoadError}
>
<div
style={{
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'center',
gap: 32,
width: 'max-content',
minWidth: '100%'
}}
>
{Array.from(new Array(numPages), (_el, index) => (
<Page
key={`page_${index + 1}`}
pageNumber={index + 1}
scale={scale}
renderTextLayer={panMode != true}
renderAnnotationLayer={panMode != true}
/>
))}
</div>
</Document>
</div>
</ScrollBox>
) : null}
</div>
)
}
PDFViewer.propTypes = {
file: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(Blob),
PropTypes.instanceOf(ArrayBuffer),
PropTypes.object
]),
scale: PropTypes.number,
panMode: PropTypes.bool,
loading: PropTypes.bool,
onLoadSuccess: PropTypes.func,
onLoadError: PropTypes.func
}
export default PDFViewer

View File

@ -7,15 +7,19 @@ import {
useCallback
} from 'react'
import PropTypes from 'prop-types'
import { Flex, Button, Input, Select } from 'antd'
import { Flex, Button, Input, Select, Typography, Card } 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 PDFViewer from './PDFViewer.jsx'
import ScrollBox from './ScrollBox.jsx'
import { ApiServerContext } from '../context/ApiServerContext.jsx'
import HProgress from './HProgress.jsx'
const { Text } = Typography
const noop = () => {}
@ -34,11 +38,16 @@ const TemplatePreview = ({
const savedScrollRef = useRef({ top: 0, left: 0 })
const iframeSizeObserverRef = useRef(null)
const previewRequestIdRef = useRef(0)
const { fetchTemplatePreview, fetchTemplatePDF } =
useContext(ApiServerContext)
const renderUnsubscribeRef = useRef(null)
const {
fetchTemplatePreview,
fetchTemplateDownload,
subscribeToObjectEvent
} = useContext(ApiServerContext)
const [previewContentHTML, setPreviewContentHTML] = useState('')
const [pdfBlob, setPDFBlob] = useState(null)
const [reloadLoading, setReloadLoading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(null)
const [previewScale, setPreviewScale] = useState(1)
const [previewType, setPreviewType] = useState('HTML')
const [panMode, setPanMode] = useState(false)
@ -162,6 +171,35 @@ const TemplatePreview = ({
setPreviewContentHTML(html)
}
const stopRenderProgressListener = useCallback(() => {
if (typeof renderUnsubscribeRef.current === 'function') {
renderUnsubscribeRef.current()
}
renderUnsubscribeRef.current = null
}, [])
const applyPdfResult = useCallback(
(result, requestId) => {
if (requestId !== previewRequestIdRef.current) {
return
}
setReloadLoading(false)
setDownloadProgress(null)
stopRenderProgressListener()
if (result?.error) {
onPreviewMessage(result.error, true)
return
}
setPDFBlob(
new Blob([result.buffer || result.pdf], {
type: result.mime || 'application/pdf'
})
)
onPreviewMessage('No issues found.', false)
},
[onPreviewMessage, stopRenderProgressListener]
)
const reloadPreviewPDF = useCallback(
(content, testObject = {}) => {
const templateId = documentTemplate?._id
@ -170,24 +208,117 @@ const TemplatePreview = ({
}
const requestId = ++previewRequestIdRef.current
stopRenderProgressListener()
setPDFBlob(null)
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)
setDownloadProgress({ progress: 0, message: 'Starting render' })
setPDFBlob(pdfUrl)
onPreviewMessage('No issues found.', false)
const startDownload = async () => {
try {
const startResult = await fetchTemplateDownload(
templateId,
content,
testObject,
'pdf',
{ async: true }
)
if (requestId !== previewRequestIdRef.current) {
return
}
if (startResult?.error || !startResult?.renderRequestId) {
applyPdfResult(startResult, requestId)
return
}
const renderRequestId = startResult.renderRequestId
const eventType = `render:${renderRequestId}`
let fetched = false
const fetchRenderedFile = async () => {
if (fetched) {
return true
}
try {
const result = await fetchTemplateDownload(
templateId,
content,
testObject,
'pdf',
{ renderRequestId }
)
fetched = true
applyPdfResult(result, requestId)
return true
} catch (err) {
if (err?.response?.status === 404) {
return false
}
applyPdfResult(
{
error: err?.message || 'Failed to download rendered template.'
},
requestId
)
return true
}
}
const unsubscribe = subscribeToObjectEvent(
renderRequestId,
'render',
eventType,
(event) => {
if (requestId !== previewRequestIdRef.current) {
return
}
setDownloadProgress({
progress: Number(event?.progress) || 0,
message: event?.message || ''
})
if (event?.error) {
applyPdfResult({ error: event.error }, requestId)
return
}
if (Number(event?.progress) >= 1) {
fetchRenderedFile()
}
}
)
const pollId = window.setInterval(async () => {
if (requestId !== previewRequestIdRef.current) {
window.clearInterval(pollId)
return
}
const didFetch = await fetchRenderedFile()
if (didFetch) {
window.clearInterval(pollId)
}
}, 1000)
renderUnsubscribeRef.current = () => {
window.clearInterval(pollId)
if (typeof unsubscribe === 'function') {
unsubscribe()
}
}
} catch (err) {
applyPdfResult(
{ error: err?.message || 'Failed to start template download.' },
requestId
)
}
})
}
startDownload()
},
[documentTemplate?._id, fetchTemplatePDF, onPreviewMessage]
[
applyPdfResult,
documentTemplate?._id,
fetchTemplateDownload,
stopRenderProgressListener,
subscribeToObjectEvent
]
)
const reloadPreview = useCallback(
@ -218,18 +349,13 @@ const TemplatePreview = ({
const templateContent = documentTemplate?.content
const templateId = documentTemplate?._id
// Move useEffect to component level and use state to track objectData changes
useEffect(() => {
if (!templateId || !templateContent) {
if (!templateId || !templateContent || previewType != 'HTML') {
return
}
const timeoutId = window.setTimeout(() => {
if (previewType == 'HTML') {
reloadPreview(templateContent, objectData, previewScale)
} else {
reloadPreviewPDF(templateContent, objectData)
}
reloadPreview(templateContent, objectData, previewScale)
}, 300)
return () => {
@ -241,15 +367,31 @@ const TemplatePreview = ({
templateId,
previewScale,
previewType,
reloadPreview,
reloadPreviewPDF
reloadPreview
])
useEffect(() => {
if (!templateId || !templateContent || previewType != 'PDF') {
return
}
setPDFBlob(null)
const timeoutId = window.setTimeout(() => {
reloadPreviewPDF(templateContent, objectData)
}, 300)
return () => {
window.clearTimeout(timeoutId)
}
}, [objectData, templateContent, templateId, previewType, reloadPreviewPDF])
useEffect(() => {
return () => {
iframeSizeObserverRef.current?.disconnect()
stopRenderProgressListener()
}
}, [])
}, [stopRenderProgressListener])
useEffect(() => {
if (!panMode) {
@ -319,6 +461,8 @@ const TemplatePreview = ({
}
}, [isPanning])
const showProgressOverlay = previewType != 'HTML' && downloadProgress != null
return (
<Flex vertical gap={'middle'} style={{ height: '100%' }}>
<Flex gap={'small'}>
@ -329,8 +473,8 @@ const TemplatePreview = ({
objectType={documentTemplate?.objectType}
name={'testObject'}
isEditing={true}
objectData={objectData}
disabled={!isEditing || objectData?.global}
objectData={documentTemplate}
disabled={!isEditing || documentTemplate?.global}
type={'object'}
style={{ minWidth: 0 }}
/>
@ -341,7 +485,7 @@ const TemplatePreview = ({
)}
<Button
icon={<InfoCircleIcon />}
disabled={objectData?.global}
disabled={documentTemplate?.global}
onClick={() => {
onTestObjectOpen()
}}
@ -357,7 +501,7 @@ const TemplatePreview = ({
<PanIcon />
)
}
disabled={loading || reloadLoading || previewType == 'PDF'}
disabled={loading || reloadLoading}
onClick={() => {
setPanMode((prev) => !prev)
}}
@ -367,19 +511,19 @@ const TemplatePreview = ({
onClick={() => {
setPreviewScale((prev) => prev + 0.05)
}}
disabled={loading || reloadLoading || previewType == 'PDF'}
disabled={loading || reloadLoading}
/>
<Button
icon={<MinusIcon />}
onClick={() => {
setPreviewScale((prev) => prev - 0.05)
setPreviewScale((prev) => Math.max(0.1, prev - 0.05))
}}
disabled={loading || reloadLoading || previewType == 'PDF'}
disabled={loading || reloadLoading}
/>
<Button
readOnly={true}
style={{ width: '65px' }}
disabled={loading || reloadLoading || previewType == 'PDF'}
disabled={loading || reloadLoading}
onClick={() => {
setPreviewScale(1)
}}
@ -392,11 +536,15 @@ const TemplatePreview = ({
{ value: 'HTML', label: 'HTML' },
{ value: 'PDF', label: 'PDF' }
]}
style
loading={loading || reloadLoading}
disabled={loading || reloadLoading}
value={previewType}
onChange={(value) => {
setPreviewType(value)
if (value == 'PDF') {
setPDFBlob(null)
}
}}
/>
) : null}
@ -406,9 +554,36 @@ const TemplatePreview = ({
style={{
flex: '1 1 auto',
minHeight: 0,
border: '1px solid #85858541'
border: '1px solid #85858541',
position: 'relative'
}}
>
<div
className={`previewProgressOverlay ${showProgressOverlay ? 'visible' : 'hidden'}`}
>
{showProgressOverlay ? (
<Flex
style={{ width: '100%', height: '100%' }}
align='center'
justify='center'
>
<Card style={{ minWidth: 360 }}>
<Flex vertical gap={'small'} style={{ width: '100%' }}>
<Text ellipsis style={{ width: '100%', minWidth: 0 }}>
{downloadProgress.message || 'Rendering...'}
</Text>
<HProgress
percent={Math.round(
(Number(downloadProgress.progress) || 0) * 100
)}
status='active'
/>
</Flex>
</Card>
</Flex>
) : null}
</div>
{previewType == 'HTML' ? (
<ScrollBox
scrollableNodeProps={{ ref: scrollElementRef }}
@ -421,11 +596,7 @@ const TemplatePreview = ({
style={{
width: iframeSize.width,
height: iframeSize.height,
cursor: panMode
? isPanning
? 'grabbing'
: 'grab'
: undefined
cursor: panMode ? (isPanning ? 'grabbing' : 'grab') : undefined
}}
>
<iframe
@ -446,14 +617,13 @@ const TemplatePreview = ({
</div>
</ScrollBox>
) : (
<iframe
ref={iframeRef}
src={pdfBlob}
frameBorder='0'
style={{
width: '100%',
height: '100%',
border: 0
<PDFViewer
file={pdfBlob}
scale={previewScale}
panMode={panMode}
loading={false}
onLoadError={() => {
onPreviewMessage('Failed to render PDF preview.', true)
}}
/>
)}
@ -464,7 +634,7 @@ const TemplatePreview = ({
TemplatePreview.propTypes = {
loading: PropTypes.bool,
objectData: PropTypes.object,
objectData: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
documentTemplate: PropTypes.object,
isEditing: PropTypes.bool,
style: PropTypes.object,

View File

@ -1865,16 +1865,30 @@ const ApiServerProvider = ({ children }) => {
}
}
const fetchTemplateDownload = async (id, content, object, type = 'pdf') => {
const fetchTemplateDownload = async (
id,
content,
object,
type = 'pdf',
extra = {}
) => {
logger.debug(`Fetching template download as ${type}...`)
const { async: asyncMode, renderRequestId } = extra
const response = await axios.post(
`${config.backendUrl}/documenttemplates/${id}/download`,
{
content,
object
object,
...(asyncMode === true ? { async: true } : {}),
...(renderRequestId ? { renderRequestId } : {})
},
{
params: { type, padding: true },
params: {
type,
padding: true,
...(asyncMode === true ? { async: true } : {}),
...(renderRequestId ? { renderRequestId } : {})
},
responseType: 'arraybuffer',
headers: {
Authorization: `Bearer ${token}`
@ -1892,11 +1906,109 @@ const ApiServerProvider = ({ children }) => {
}
}
const fetchTemplatePDF = async (id, content, testObject, callback) => {
const requestAsyncTemplateDownload = async (
id,
content,
object,
type = 'pdf',
onProgress
) => {
const startResult = await fetchTemplateDownload(id, content, object, type, {
async: true
})
if (startResult?.error) {
return startResult
}
if (!startResult?.renderRequestId) {
return startResult
}
const renderRequestId = startResult.renderRequestId
if (typeof onProgress === 'function') {
onProgress({ progress: 0, message: 'Starting render' })
}
return new Promise((resolve, reject) => {
let settled = false
let unsubscribe
const finish = (result, error) => {
if (settled) {
return
}
settled = true
window.clearTimeout(timeoutId)
window.clearInterval(pollId)
if (typeof unsubscribe === 'function') {
unsubscribe()
}
if (error) {
reject(error)
return
}
resolve(result)
}
const tryFetchRenderedFile = async () => {
try {
const result = await fetchTemplateDownload(id, content, object, type, {
renderRequestId
})
finish(result)
return true
} catch (err) {
if (err?.response?.status === 404) {
return false
}
finish(null, err)
return true
}
}
unsubscribe = subscribeToObjectEvent(
renderRequestId,
'render',
`render:${renderRequestId}`,
(event) => {
if (typeof onProgress === 'function') {
onProgress(event)
}
if (event?.error) {
finish({ error: event.error })
return
}
if (Number(event?.progress) >= 1) {
tryFetchRenderedFile()
}
}
)
const pollId = window.setInterval(tryFetchRenderedFile, 1000)
const timeoutId = window.setTimeout(() => {
finish(null, new Error('Timed out waiting for template render.'))
}, 120000)
})
}
const fetchTemplatePDF = async (
id,
content,
testObject,
callback,
onProgress
) => {
logger.debug('Fetching pdf template...')
try {
const result = await fetchTemplateDownload(id, content, testObject, 'pdf')
const payload = result.error ? result : { pdf: result.buffer || result }
const result = await requestAsyncTemplateDownload(
id,
content,
testObject,
'pdf',
onProgress
)
const payload = result.error
? result
: { pdf: result.buffer || result, mime: result.mime }
if (typeof callback === 'function') {
callback(payload)
}
@ -1921,7 +2033,12 @@ const ApiServerProvider = ({ children }) => {
) => {
logger.debug(`Downloading template as ${type}...`)
try {
const result = await fetchTemplateDownload(id, content, object, type)
const result = await requestAsyncTemplateDownload(
id,
content,
object,
type
)
if (result?.error) {
if (callback) {
callback(result.error)
@ -2402,6 +2519,7 @@ const ApiServerProvider = ({ children }) => {
fetchTemplatePreview,
fetchTemplateFormat,
fetchTemplatePDF,
fetchTemplateDownload,
fetchNotes,
downloadTemplate,
downloadTemplatePDF,

View File

@ -245,8 +245,9 @@ export const DocumentTemplate = {
name: 'testObject',
label: 'Test Object',
required: false,
type: 'codeBlock',
language: 'json'
type: 'object',
objectType: (objectData) => objectData?.objectType,
showHyperlink: true
}
]
}