diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css
index e82e83b6..61b73d21 100644
--- a/assets/stylesheets/App.css
+++ b/assets/stylesheets/App.css
@@ -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;
+}
diff --git a/package.json b/package.json
index 6113b489..6f8f66ca 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b4848732..f4d0abed 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/src/components/Dashboard/common/PDFPreview.jsx b/src/components/Dashboard/common/PDFPreview.jsx
new file mode 100644
index 00000000..78fceab0
--- /dev/null
+++ b/src/components/Dashboard/common/PDFPreview.jsx
@@ -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 (
+
+
+
+ ) : (
+
+ )
+ }
+ disabled={loading}
+ onClick={() => {
+ setPanMode((prev) => !prev)
+ }}
+ />
+ }
+ onClick={() => {
+ setPreviewScale((prev) => prev + 0.05)
+ }}
+ disabled={loading}
+ />
+ }
+ onClick={() => {
+ setPreviewScale((prev) => Math.max(0.1, prev - 0.05))
+ }}
+ disabled={loading}
+ />
+
+
+
+
+
+ )
+}
+
+PDFPreview.propTypes = {
+ file: PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.instanceOf(Blob),
+ PropTypes.instanceOf(ArrayBuffer),
+ PropTypes.object
+ ]),
+ loading: PropTypes.bool,
+ style: PropTypes.object
+}
+
+export default PDFPreview
diff --git a/src/components/Dashboard/common/PDFViewer.jsx b/src/components/Dashboard/common/PDFViewer.jsx
new file mode 100644
index 00000000..11edae3c
--- /dev/null
+++ b/src/components/Dashboard/common/PDFViewer.jsx
@@ -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 (
+
+ {showLoadingPlaceholder ? (
+
+
+
+ ) : null}
+ {activeFile ? (
+
+
+
+ Failed to load PDF.
+
+ }
+ onLoadSuccess={handleDocumentLoadSuccess}
+ onLoadError={handleDocumentLoadError}
+ >
+
+ {Array.from(new Array(numPages), (_el, index) => (
+
+ ))}
+
+
+
+
+ ) : null}
+
+ )
+}
+
+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
diff --git a/src/components/Dashboard/common/TemplatePreview.jsx b/src/components/Dashboard/common/TemplatePreview.jsx
index 30c419de..7259d6a3 100644
--- a/src/components/Dashboard/common/TemplatePreview.jsx
+++ b/src/components/Dashboard/common/TemplatePreview.jsx
@@ -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 (
@@ -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 = ({
)}
}
- disabled={objectData?.global}
+ disabled={documentTemplate?.global}
onClick={() => {
onTestObjectOpen()
}}
@@ -357,7 +501,7 @@ const TemplatePreview = ({
)
}
- 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}
/>
}
onClick={() => {
- setPreviewScale((prev) => prev - 0.05)
+ setPreviewScale((prev) => Math.max(0.1, prev - 0.05))
}}
- disabled={loading || reloadLoading || previewType == 'PDF'}
+ disabled={loading || reloadLoading}
/>