pse2_ff/project/frontend/src/components/pdfViewer.tsx

247 lines
6.8 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { Document, Page } from "react-pdf";
import "react-pdf/dist/esm/Page/AnnotationLayer.css";
import "react-pdf/dist/esm/Page/TextLayer.css";
import ArrowCircleLeftIcon from "@mui/icons-material/ArrowCircleLeft";
import ArrowCircleRightIcon from "@mui/icons-material/ArrowCircleRight";
import { Box, IconButton } from "@mui/material";
import type {
CustomTextRenderer,
OnGetTextSuccess,
} from "node_modules/react-pdf/dist/esm/shared/types";
import { socket } from "../socket";
import { API_HOST } from "../util/api";
import { highlightPattern } from "../util/highlighting";
interface PDFViewerProps {
pitchBookId: string;
currentPage?: number;
onPageChange?: (page: number) => void;
highlight: { text: string; page: number }[];
focusHighlight: { text: string; page: number };
}
export default function PDFViewer({
pitchBookId,
currentPage,
onPageChange,
highlight = [],
focusHighlight,
}: PDFViewerProps) {
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(currentPage || 1);
const [containerWidth, setContainerWidth] = useState<number | null>(null);
const [pdfKey, setPdfKey] = useState(Date.now());
const containerRef = useRef<HTMLDivElement>(null);
const [posHighlight, setPosHighlight] = useState<string[]>([]);
const [posHighlightFocus, setPosHighlightFocus] = useState<string[]>([]);
const [textContent, setTextContent] = useState<
{ posKey: string; text: string; i: number }[]
>([]);
const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
setNumPages(numPages);
};
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth);
}
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => window.removeEventListener("resize", updateWidth);
}, []);
useEffect(() => {
if (currentPage && currentPage !== pageNumber) {
setPageNumber(currentPage);
}
}, [currentPage, pageNumber]);
useEffect(() => {
const handleProgress = (data: { id: number; progress: number }) => {
if (data.id.toString() === pitchBookId && data.progress === 50) {
setPdfKey(Date.now());
}
};
socket.on("progress", handleProgress);
return () => {
socket.off("progress", handleProgress);
};
}, [pitchBookId]);
const handlePageChange = (newPage: number) => {
setPageNumber(newPage);
onPageChange?.(newPage);
};
const textRenderer: CustomTextRenderer = useCallback(
(textItem) => {
return highlightPattern(
textItem.str,
`${textItem.width};${textItem.height};${textItem.transform}`,
posHighlight,
posHighlightFocus,
);
},
[posHighlight, posHighlightFocus],
);
useEffect(() => {
const tmpPos: string[] = [];
const tmpPosHighlight: string[] = [];
if (textContent.length === 0) {
setPosHighlight([]);
setPosHighlightFocus([]);
return;
}
const findTextPositions = (searchText: string): number[] => {
const positions: number[] = [];
const normalizedSearch = searchText.toLowerCase().trim();
textContent.forEach((item, index) => {
if (item.text.toLowerCase().trim() === normalizedSearch) {
positions.push(index);
}
});
if (positions.length === 0) {
let cumulativeText = '';
const textBoundaries: { start: number; end: number; index: number }[] = [];
textContent.forEach((item, index) => {
const start = cumulativeText.length;
cumulativeText += item.text;
const end = cumulativeText.length;
textBoundaries.push({ start, end, index });
});
const lowerCumulative = cumulativeText.toLowerCase();
let searchIndex = lowerCumulative.indexOf(normalizedSearch);
while (searchIndex !== -1) {
const endIndex = searchIndex + normalizedSearch.length;
textBoundaries.forEach(boundary => {
if (
(boundary.start <= searchIndex && searchIndex < boundary.end) || // Search starts in this item
(boundary.start < endIndex && endIndex <= boundary.end) || // Search ends in this item
(searchIndex <= boundary.start && boundary.end <= endIndex) // This item is completely within search
) {
if (!positions.includes(boundary.index)) {
positions.push(boundary.index);
}
}
});
searchIndex = lowerCumulative.indexOf(normalizedSearch, searchIndex + 1);
}
}
return positions.sort((a, b) => a - b);
};
highlight
.filter(h => h.page === pageNumber)
.forEach(highlightItem => {
const positions = findTextPositions(highlightItem.text);
positions.forEach(pos => {
if (pos >= 0 && pos < textContent.length) {
tmpPos.push(textContent[pos].posKey);
}
});
});
if (focusHighlight?.page === pageNumber && focusHighlight.text) {
const positions = findTextPositions(focusHighlight.text);
positions.forEach(pos => {
if (pos >= 0 && pos < textContent.length) {
tmpPosHighlight.push(textContent[pos].posKey);
}
});
}
setPosHighlight([...new Set(tmpPos)]);
setPosHighlightFocus([...new Set(tmpPosHighlight)]);
}, [highlight, focusHighlight, pageNumber, textContent]);
const onGetTextSuccess: OnGetTextSuccess = useCallback((fullText) => {
setTextContent(
fullText.items.map((e, i) => ({
posKey: `${"width" in e ? e.width : 0};${"height" in e ? e.height : 0};${"transform" in e ? e.transform : ""}`,
text: "str" in e ? e.str : "",
i,
})),
);
}, []);
return (
<Box
display="flex"
flexDirection="column"
justifyContent="center"
alignItems="center"
width="100%"
height="auto"
>
<Box
ref={containerRef}
sx={{
width: "100%",
height: "auto",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<Document
key={pdfKey}
file={`${API_HOST}/api/pitch_book/${pitchBookId}/download`}
onLoadSuccess={onDocumentLoadSuccess}
onLoadError={(error) =>
console.error("Es gab ein Fehler beim Laden des PDFs:", error)
}
onSourceError={(error) => console.error("Ungültige PDF:", error)}
>
{containerWidth && (
<Page
pageNumber={pageNumber}
width={containerWidth * 0.98}
customTextRenderer={textRenderer}
onGetTextSuccess={onGetTextSuccess}
/>
)}
</Document>
</Box>
<Box
mt={1}
display="flex"
alignItems="center"
justifyContent="center"
gap={1}
p={1}
>
<IconButton
disabled={pageNumber <= 1}
onClick={() => handlePageChange(pageNumber - 1)}
>
<ArrowCircleLeftIcon fontSize="large" />
</IconButton>
<span>
{pageNumber} / {numPages}
</span>
<IconButton
disabled={pageNumber >= (numPages || 1)}
onClick={() => handlePageChange(pageNumber + 1)}
>
<ArrowCircleRightIcon fontSize="large" />
</IconButton>
</Box>
</Box>
);
}