ToolJet/frontend/src/AppBuilder/Widgets/PDF.jsx
Nakul Nagargade 433e1bd4c4
Enhance TypeScript support in frontend configuration (#15576)
* test: verify pre-commit hook

* fix: clean up code formatting and improve readability across multiple components

* chore: update subproject commit reference in frontend/ee

* chore: update eslint to version 9.26.0 and remove unused dependencies from package.json

fix: update submodule reference in server/ee

* chore: refactor ESLint configuration and add quiet linting script; update components to disable specific ESLint rules

* chore: add GitHub Copilot review instructions for App Builder team

Covers backward compatibility rules, styling conventions, state management,
resolution system, widget definitions, and common review flags.

* chore: add review instructions for App Builder, Data Migrations, Server Widget Config, Widget Components, and Widget Config

* Enhance TypeScript support in frontend configuration

- Added TypeScript parser and linting rules to ESLint configuration.
- Updated Babel configuration to include TypeScript preset.
- Modified package.json and package-lock.json to include TypeScript and related dependencies.
- Introduced tsconfig.json for TypeScript compiler options.
- Updated Webpack configuration to support .ts and .tsx file extensions.
- Adjusted linting and formatting scripts to include TypeScript files.

* chore: update TypeScript ESLint packages and subproject commits

---------

Co-authored-by: kavinvenkatachalam <kavin.saratha@gmail.com>
Co-authored-by: Johnson Cherian <johnsonc.dev@gmail.com>
2026-03-19 12:41:32 +05:30

262 lines
8.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css'; // Required to fix duplicate text appearing at the bottom from the previous page
import { debounce } from 'lodash';
// Constants for password prompt reasons (react-pdf v10 / pdfjs v4)
const PasswordResponses = {
NEED_PASSWORD: 1,
INCORRECT_PASSWORD: 2,
};
// PDF.js v5 worker setup for react-pdf v10: provide a URL string to the worker bundle
// Using new URL keeps this portable across bundlers (Webpack 5, Vite, etc.)
pdfjs.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString();
const PDF = React.memo(({ styles, properties, width, height, componentName, dataCy }) => {
const { visibility, boxShadow, borderColor, borderRadius } = styles;
const { url, scale, pageControls, showDownloadOption } = properties;
const [numPages, setNumPages] = useState(null);
const [pageNumber, setPageNumber] = useState(null);
const pageRef = useRef([]);
const documentRef = useRef(null);
const hasScrollRef = useRef(false);
const [error, setError] = useState(true);
const [pageLoading, setPageLoading] = useState(true);
const [hasButtonClicked, setButtonClick] = useState(false);
const [isPasswordPromptClosed, setIsPasswordPromptClosed] = useState(false);
const onDocumentLoadSuccess = async (document) => {
const { numPages: nextNumPages } = document;
setNumPages(nextNumPages);
setPageNumber(1);
setError(false);
setPageLoading(false);
};
const onDocumentLoadError = () => {
setError(true);
};
useEffect(() => {
setPageLoading(true);
}, [url]);
const options = {
root: document.querySelector('#pdf-wrapper'),
rootMargin: '0px',
threshold: 0.7,
};
const trackIntersection = (entries) => {
let isCaptured = false;
entries.forEach((entry) => {
if (entry.isIntersecting && !isCaptured && hasScrollRef.current) {
isCaptured = true;
const currentPage = parseInt(entry.target.getAttribute('data-page-number'));
if (pageNumber !== currentPage) setPageNumber(currentPage);
}
});
};
useEffect(() => {
if (numPages === 0 || numPages === null) return;
const observer = new IntersectionObserver(trackIntersection, options);
document.querySelectorAll('.react-pdf__Page').forEach((elem) => {
if (elem) observer.observe(elem);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [numPages, options]);
useEffect(() => {
setIsPasswordPromptClosed(false);
}, [url]);
const updatePage = useCallback(
(offset) => {
const { offsetTop } = pageRef.current[pageNumber + offset - 1];
documentRef.current.scrollTop = offsetTop;
setButtonClick(true);
setPageNumber((prevPageNumber) => (prevPageNumber || 1) + offset);
},
[pageNumber]
);
// styles for download icon
const downloadIconOuterWrapperStyles = {
backgroundColor: 'var(--cc-surface1-surface)',
borderRadius: '4px',
height: '36px',
padding: '0.5rem',
cursor: 'pointer',
};
const downloadIconImgStyle = {
width: '15px',
height: '15px',
};
function onPassword(callback, reason) {
function callbackProxy(password) {
setIsPasswordPromptClosed(false);
if (password === null) {
setIsPasswordPromptClosed(true);
return;
}
callback(password);
}
switch (reason) {
case PasswordResponses.NEED_PASSWORD: {
const password = prompt('Enter the password to open this PDF file.');
callbackProxy(password);
break;
}
case PasswordResponses.INCORRECT_PASSWORD: {
const password = prompt('Invalid password. Please try again.');
callbackProxy(password);
break;
}
default:
}
}
const renderPDF = () => {
if (isPasswordPromptClosed)
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column' }}>
<p style={{ marginBottom: '6px' }}>Password prompt closed</p>
<button
class="pdf-retry-button"
data-cy="draggable-widget-button3"
type="default"
onClick={() => setIsPasswordPromptClosed(false)}
>
<div>
<div>
<span>
<p class="tj-text-sm">Retry</p>
</span>
</div>
</div>
</button>
</div>
);
return (
<Document
file={url}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={onDocumentLoadError}
onPassword={onPassword}
className="pdf-document"
>
{Array.from(new Array(numPages), (el, index) => (
<Page
pageNumber={index + 1}
width={scale ? width - 12 : undefined}
height={scale ? undefined : height}
key={`page_${index + 1}`}
inputRef={(el) => (pageRef.current[index] = el)}
/>
))}
</Document>
);
};
async function downloadFile(url, pdfName) {
const pdf = await fetch(url);
const pdfBlog = await pdf.blob();
const pdfURL = URL.createObjectURL(pdfBlog);
const anchor = document.createElement('a');
anchor.href = pdfURL;
anchor.download = pdfName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(pdfURL);
}
const handleScroll = () => {
if (hasButtonClicked) return setButtonClick(false);
if (!hasScrollRef.current) hasScrollRef.current = true;
debounce(() => {
if (hasScrollRef.current) hasScrollRef.current = false;
}, 150);
};
return (
<div style={{ display: visibility ? 'flex' : 'none', width: width - 3, height, boxShadow }} data-cy={dataCy}>
<div
className="d-flex position-relative h-100 flex-column"
style={{
margin: '0 auto',
overflow: 'hidden',
borderRadius: `${borderRadius}px`,
border: `1px solid ${borderColor}`,
}}
>
<div
className="scrollable h-100 col position-relative"
id="pdf-wrapper"
ref={documentRef}
onScroll={handleScroll}
>
{url === '' ? 'No PDF file specified' : renderPDF()}
</div>
{!error && !pageLoading && (showDownloadOption || pageControls) && (
<div
className={`d-flex ${
pageControls ? 'justify-content-between' : 'justify-content-end'
} py-3 px-3 align-items-baseline border-top border-light`}
style={{ backgroundColor: 'var(--cc-surface1-surface)', color: 'var(--cc-primary-text)' }}
>
{pageControls && (
<>
<div className="pdf-page-controls">
<button
disabled={pageNumber <= 1}
onClick={() => updatePage(-1)}
type="button"
aria-label="Previous page"
style={{ backgroundColor: 'var(--cc-surface1-surface)' }}
>
</button>
<span>
{pageNumber} of {numPages}
</span>
<button
disabled={pageNumber >= numPages}
onClick={() => updatePage(1)}
type="button"
aria-label="Next page"
style={{ backgroundColor: 'var(--cc-surface1-surface)' }}
>
</button>
</div>
</>
)}
{showDownloadOption && (
<div
className="download-icon-outer-wrapper text-dark"
style={downloadIconOuterWrapperStyles}
onClick={() => downloadFile(url, componentName)}
>
<img
src="assets/images/icons/download.svg"
alt="download logo"
style={downloadIconImgStyle}
className="mx-1"
/>
<span className="mx-1" style={{ color: 'var(--cc-primary-text)' }}>
Download PDF
</span>
</div>
)}
</div>
)}
</div>
</div>
);
});
export default PDF;