From 85273bea1b46c3efeb9e41b6ead70997f1ed0d7a Mon Sep 17 00:00:00 2001 From: Tom Butcher Date: Sat, 22 Aug 2026 12:55:50 +0100 Subject: [PATCH] Add PDF Preview and Viewer Components with Enhanced Styling - 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. --- assets/stylesheets/App.css | 23 ++ package.json | 2 + pnpm-lock.yaml | 186 ++++++++++++ .../Dashboard/common/PDFPreview.jsx | 85 ++++++ src/components/Dashboard/common/PDFViewer.jsx | 241 ++++++++++++++++ .../Dashboard/common/TemplatePreview.jsx | 270 ++++++++++++++---- .../Dashboard/context/ApiServerContext.jsx | 132 ++++++++- src/database/models/DocumentTemplate.js | 5 +- 8 files changed, 885 insertions(+), 59 deletions(-) create mode 100644 src/components/Dashboard/common/PDFPreview.jsx create mode 100644 src/components/Dashboard/common/PDFViewer.jsx 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 ( + + + + + +
+ +
+
+ ) +} + +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 = ({ )}