Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add PDF viewer support to DocumentViewerWidget #38580

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@
"react-media-recorder": "^1.6.1",
"react-modal": "^3.15.1",
"react-page-visibility": "^7.0.0",
"react-pdf": "^9.2.1",
"react-player": "^2.3.1",
"react-qr-barcode-scanner": "^1.0.6",
"react-rating": "^2.0.5",
Expand Down
204 changes: 204 additions & 0 deletions app/client/src/widgets/DocumentViewerWidget/component/PdfViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import React, { useState, useEffect, useRef } from "react";
import styled from "styled-components";
import { Document, Page, pdfjs } from "react-pdf";
import "react-pdf/dist/esm/Page/AnnotationLayer.css";
import "react-pdf/dist/esm/Page/TextLayer.css";

// Set up PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;

const ViewerContainer = styled.div`
width: 100%;
height: 100%;
position: relative;
background: #fff;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
`;

const PageContainer = styled.div<{ rotation: number }>`
margin: 10px 0;
position: relative;
transform: rotate(${(props) => props.rotation}deg);
transition: transform 0.3s ease;
`;

const ControlsContainer = styled.div`
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
gap: 8px;
z-index: 10;
background: rgba(255, 255, 255, 0.9);
padding: 8px;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
`;

const RotateButton = styled.button`
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
font-size: 16px;

&:hover {
background: #f5f5f5;
}

&:active {
background: #e5e5e5;
}

&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
`;

interface PDFViewerProps {
url?: string;
blob?: Blob;
}

const PDFViewer = ({ blob, url }: PDFViewerProps) => {
const [numPages, setNumPages] = useState<number>(0);
const [pageRotations, setPageRotations] = useState<Record<number, number>>(
{},
);
const [activePage, setActivePage] = useState<number | null>(null);
const observerRef = useRef<IntersectionObserver | null>(null);
const pagesRef = useRef<
Map<number, { element: Element; visibility: number }>
>(new Map());

useEffect(() => {
// Create intersection observer
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const pageNumber = parseInt(
entry.target.getAttribute("data-page-number") || "0",
);

if (pageNumber) {
pagesRef.current.set(pageNumber, {
element: entry.target,
visibility: entry.intersectionRatio,
});
}
});

// Find the page with highest visibility
let maxVisibility = 0;
let mostVisiblePage = null;

pagesRef.current.forEach((data, pageNum) => {
if (data.visibility > maxVisibility) {
maxVisibility = data.visibility;
mostVisiblePage = pageNum;
}
});

if (mostVisiblePage && mostVisiblePage !== activePage) {
setActivePage(mostVisiblePage);
}
},
{
threshold: Array.from({ length: 100 }, (_, i) => i / 100), // Generate thresholds from 0 to 1
root: null, // Use viewport as root
},
);

return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, []);

const handleRotatePage = (direction: "left" | "right") => {
if (!activePage) return;

setPageRotations((prev) => ({
...prev,
[activePage]:
((prev[activePage] || 0) + (direction === "left" ? -90 : 90)) % 360,
}));
};

function onDocumentLoadSuccess({ numPages }: { numPages: number }): void {
setNumPages(numPages);
setActivePage(1);
}

// Determine the file prop based on whether we have a URL or blob
const file = blob || url;

if (!file) {
return null;
}

return (
<ViewerContainer>
<Document file={file} onLoadSuccess={onDocumentLoadSuccess}>
{Array.from(new Array(numPages), (_, index) => {
const pageNumber = index + 1;

return (
<PageContainer
key={`page_${pageNumber}`}
ref={(element) => {
if (element && observerRef.current) {
element.setAttribute(
"data-page-number",
pageNumber.toString(),
);
observerRef.current.observe(element);
}
}}
rotation={pageRotations[pageNumber] || 0}
>
<Page
key={`page_${pageNumber}`}
pageNumber={pageNumber}
renderAnnotationLayer={false}
renderTextLayer={false}
/>
</PageContainer>
);
})}
</Document>
<ControlsContainer>
<RotateButton
aria-label="Rotate active page left"
disabled={!activePage}
onClick={() => handleRotatePage("left")}
tabIndex={0}
title="Rotate left"
>
</RotateButton>
<RotateButton
aria-label="Rotate active page right"
disabled={!activePage}
onClick={() => handleRotatePage("right")}
tabIndex={0}
title="Rotate right"
>
</RotateButton>
</ControlsContainer>
</ViewerContainer>
);
};

export default PDFViewer;
15 changes: 14 additions & 1 deletion app/client/src/widgets/DocumentViewerWidget/component/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const DocViewer = lazy(async () =>
const XlsxViewer = lazy(async () =>
retryPromise(async () => import("./XlsxViewer")),
);
const PdfViewer = lazy(async () =>
retryPromise(async () => import("./PdfViewer")),
);

const ErrorWrapper = styled.div`
display: flex;
Expand Down Expand Up @@ -142,6 +145,8 @@ export const getDocViewerConfigs = (docUrl: string): ConfigResponse => {
renderer = Renderers.DOCX_VIEWER;
} else if (extension === "xlsx" || extension == "xls") {
renderer = Renderers.XLSX_VIEWER;
} else if (extension === "pdf") {
renderer = Renderers.PDF_VIEWER;
}
} else {
errorMessage = "invalid base64 data";
Expand Down Expand Up @@ -188,7 +193,9 @@ export const getDocViewerConfigs = (docUrl: string): ConfigResponse => {

if (hasExtension) {
if (validExtension) {
if (!(extension === "txt" || extension === "pdf")) {
if (extension === "pdf") {
renderer = Renderers.PDF_VIEWER;
} else if (!(extension === "txt")) {
viewer = "office";
renderer = Renderers.DOCUMENT_VIEWER;
}
Expand Down Expand Up @@ -222,6 +229,12 @@ function DocumentViewerComponent(props: DocumentViewerComponentProps) {
<XlsxViewer blob={blob} />
</Suspense>
);
case Renderers.PDF_VIEWER:
return (
<Suspense fallback={<Skeleton />}>
<PdfViewer blob={blob} url={url} />
</Suspense>
);
case Renderers.DOCUMENT_VIEWER:
return <DocumentViewer url={url} viewer={viewer} />;

Expand Down
1 change: 1 addition & 0 deletions app/client/src/widgets/DocumentViewerWidget/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const Renderers = {
DOCX_VIEWER: "DOCX_VIEWER",
XLSX_VIEWER: "XLSX_VIEWER",
ERROR: "ERROR",
PDF_VIEWER: "PDF_VIEWER",
};

export type Renderer = (typeof Renderers)[keyof typeof Renderers];
Expand Down
Loading
Loading