From a533e1ac3250ddbb8d61a67cc0bf19cd72eb4b11 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Thu, 7 May 2026 03:28:48 +0000 Subject: [PATCH] hery --- frontend/src/pages/pdf-viewer.tsx | 504 ++++++++++++++++++++++++++++-- 1 file changed, 471 insertions(+), 33 deletions(-) diff --git a/frontend/src/pages/pdf-viewer.tsx b/frontend/src/pages/pdf-viewer.tsx index 0e558b1..b1e5fba 100644 --- a/frontend/src/pages/pdf-viewer.tsx +++ b/frontend/src/pages/pdf-viewer.tsx @@ -3,6 +3,10 @@ import { mdiCheckCircleOutline, mdiCommentTextOutline, mdiFilePdfBox, + mdiFileWordOutline, + mdiMagnifyMinusOutline, + mdiMagnifyPlusOutline, + mdiRefresh, mdiShieldLockOutline, } from '@mdi/js'; import axios from 'axios'; @@ -22,6 +26,9 @@ import { useRouter } from 'next/router'; const VIEWER_SESSION_STORAGE_KEY = 'viewerAccessToken'; const PDFJS_SCRIPT_URL = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.min.js'; const PDFJS_WORKER_URL = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js'; +const MIN_ZOOM_LEVEL = 0.5; +const MAX_ZOOM_LEVEL = 2; +const ZOOM_STEP = 0.25; declare global { interface Window { @@ -93,6 +100,178 @@ function getStatusStyles(status: string) { return 'bg-amber-100 text-amber-700 border-amber-200'; } } +function clampZoomLevel(value: number) { + return Math.min(MAX_ZOOM_LEVEL, Math.max(MIN_ZOOM_LEVEL, value)); +} + +function sanitizeFileName(value: string) { + const normalizedValue = value + .replace(/\.pdf$/i, '') + .replace(/[^a-zA-Z0-9-_]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-|-$/g, ''); + + return normalizedValue || 'halaman-pdf'; +} + +function escapeHtml(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function extractPdfTextLines(items: Array<{ str?: string; transform?: number[] }>) { + const lines: Array<{ y: number; text: string }> = []; + + items.forEach((item) => { + const textValue = String(item?.str || '') + .replace(/\s+/g, ' ') + .trim(); + + if (!textValue) { + return; + } + + const yPosition = Array.isArray(item?.transform) ? Number(item.transform[5]) : Number.NaN; + const previousLine = lines[lines.length - 1]; + + if ( + !previousLine || + Number.isNaN(yPosition) || + Number.isNaN(previousLine.y) || + Math.abs(previousLine.y - yPosition) > 4 + ) { + lines.push({ y: yPosition, text: textValue }); + return; + } + + previousLine.text = `${previousLine.text} ${textValue}`.replace(/\s+/g, ' ').trim(); + }); + + return lines.map((line) => line.text).filter(Boolean); +} + +function downloadBlob(blob: Blob, fileName: string) { + const downloadUrl = window.URL.createObjectURL(blob); + const anchor = document.createElement('a'); + + anchor.href = downloadUrl; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + + window.setTimeout(() => { + window.URL.revokeObjectURL(downloadUrl); + }, 1000); +} + +function buildWordDocumentHtml({ + title, + pageNumber, + viewerName, + ruleName, + exportedAt, + previewImageDataUrl, + extractedLines, +}: { + title: string; + pageNumber: number; + viewerName: string; + ruleName: string; + exportedAt: string; + previewImageDataUrl: string; + extractedLines: string[]; +}) { + const extractedTextHtml = extractedLines.length + ? extractedLines + .map((line) => `

${escapeHtml(line)}

`) + .join('') + : '

PDF ini kemungkinan berupa scan atau gambar, sehingga file Word berisi snapshot halaman untuk ditinjau.

'; + + return ` + + + + ${escapeHtml(title)} + + + +

${escapeHtml(title)}

+

Viewer: ${escapeHtml(viewerName)}

+

Halaman yang diizinkan: ${pageNumber}

+

Aturan akses: ${escapeHtml(ruleName)}

+

Diekspor: ${escapeHtml(exportedAt)}

+ +
+

Snapshot halaman

+ Preview halaman PDF +
+ +
+

Isi teks halaman

+ ${extractedTextHtml} +
+ +`; +} + +async function renderPdfPageToImageDataUrl(pdfPage: any) { + if (typeof document === 'undefined') { + throw new Error('Export Word hanya tersedia di browser.'); + } + + const exportViewport = pdfPage.getViewport({ scale: 1.4 }); + const exportCanvas = document.createElement('canvas'); + const exportContext = exportCanvas.getContext('2d'); + + if (!exportContext) { + throw new Error('Canvas export Word tidak tersedia.'); + } + + exportCanvas.width = exportViewport.width; + exportCanvas.height = exportViewport.height; + exportContext.fillStyle = '#ffffff'; + exportContext.fillRect(0, 0, exportCanvas.width, exportCanvas.height); + + await pdfPage.render({ + canvasContext: exportContext, + viewport: exportViewport, + }).promise; + + return exportCanvas.toDataURL('image/png'); +} function loadPdfJsLibrary() { return new Promise((resolve, reject) => { @@ -127,6 +306,11 @@ function loadPdfJsLibrary() { export default function PdfViewerPage() { const router = useRouter(); const canvasRef = useRef(null); + const pdfContainerRef = useRef(null); + const pdfDocumentRef = useRef(null); + const pdfRenderTaskRef = useRef(null); + const pdfRenderNonceRef = useRef(0); + const cachedDocumentKeyRef = useRef(''); const [viewerToken, setViewerToken] = useState(''); const [session, setSession] = useState(null); const [feedbackItems, setFeedbackItems] = useState([]); @@ -134,8 +318,11 @@ export default function PdfViewerPage() { const [isPageLoading, setIsPageLoading] = useState(true); const [isPdfLoading, setIsPdfLoading] = useState(false); const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false); + const [isExportingWord, setIsExportingWord] = useState(false); + const [pdfZoom, setPdfZoom] = useState(1); const [pageError, setPageError] = useState(''); const [pdfError, setPdfError] = useState(''); + const [wordError, setWordError] = useState(''); const [feedbackError, setFeedbackError] = useState(''); const [feedbackSuccess, setFeedbackSuccess] = useState(''); @@ -157,22 +344,53 @@ export default function PdfViewerPage() { setFeedbackItems(response.data || []); }, []); - const renderAssignedPage = useCallback(async (token: string, pageNumber: number) => { - if (!pageNumber) { - return; + const resetPdfCache = useCallback(() => { + if (pdfRenderTaskRef.current?.cancel) { + try { + pdfRenderTaskRef.current.cancel(); + } catch (error) { + console.error('Failed to cancel PDF render task:', error); + } } - setPdfError(''); - setIsPdfLoading(true); + pdfRenderTaskRef.current = null; + pdfRenderNonceRef.current += 1; + setIsPdfLoading(false); - try { - const pdfjsLib = await loadPdfJsLibrary(); + if (pdfDocumentRef.current?.destroy) { + pdfDocumentRef.current.destroy().catch((error: unknown) => { + console.error('Failed to release cached PDF document:', error); + }); + } - if (!pdfjsLib) { - throw new Error('Viewer PDF tidak tersedia di browser ini.'); + pdfDocumentRef.current = null; + cachedDocumentKeyRef.current = ''; + + if (canvasRef.current) { + const context = canvasRef.current.getContext('2d'); + + if (context) { + context.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); } - pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; + canvasRef.current.width = 0; + canvasRef.current.height = 0; + } + }, []); + + const getPdfDocument = useCallback(async (token: string, assignmentId: string) => { + const pdfjsLib = await loadPdfJsLibrary(); + + if (!pdfjsLib) { + throw new Error('Viewer PDF tidak tersedia di browser ini.'); + } + + pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; + + const cacheKey = `${assignmentId}:${token}`; + + if (!pdfDocumentRef.current || cachedDocumentKeyRef.current !== cacheKey) { + resetPdfCache(); const response = await axios.get('/portal-access/document', { headers: { @@ -181,35 +399,86 @@ export default function PdfViewerPage() { responseType: 'arraybuffer', }); - const pdfDocument = await pdfjsLib.getDocument({ data: response.data }).promise; + cachedDocumentKeyRef.current = cacheKey; + pdfDocumentRef.current = await pdfjsLib.getDocument({ + data: new Uint8Array(response.data), + }).promise; + } + + return pdfDocumentRef.current; + }, [resetPdfCache]); + + const renderAssignedPage = useCallback(async ( + token: string, + assignmentId: string, + pageNumber: number, + zoomLevel: number, + ) => { + if (!assignmentId || !pageNumber) { + return; + } + + const canvas = canvasRef.current; + + if (!canvas) { + return; + } + + let renderNonce = 0; + setPdfError(''); + setIsPdfLoading(true); + + try { + const pdfDocument = await getPdfDocument(token, assignmentId); const pdfPage = await pdfDocument.getPage(pageNumber); - const canvas = canvasRef.current; - - if (!canvas) { - return; - } - const context = canvas.getContext('2d'); if (!context) { throw new Error('Canvas viewer tidak tersedia.'); } - const parentWidth = canvas.parentElement?.clientWidth || 900; + renderNonce = pdfRenderNonceRef.current + 1; + pdfRenderNonceRef.current = renderNonce; + + if (pdfRenderTaskRef.current?.cancel) { + try { + pdfRenderTaskRef.current.cancel(); + } catch (error) { + console.error('Failed to cancel previous PDF render:', error); + } + } + + const containerWidth = + pdfContainerRef.current?.clientWidth || canvas.parentElement?.clientWidth || 900; const initialViewport = pdfPage.getViewport({ scale: 1 }); - const scale = Math.min(Math.max(parentWidth / initialViewport.width, 1), 2.2); - const viewport = pdfPage.getViewport({ scale }); + const fitScale = Math.max(containerWidth / initialViewport.width, 0.35); + const viewport = pdfPage.getViewport({ + scale: Math.max(fitScale * clampZoomLevel(zoomLevel), 0.35), + }); canvas.width = viewport.width; canvas.height = viewport.height; - canvas.style.width = '100%'; - canvas.style.height = 'auto'; + canvas.style.width = `${viewport.width}px`; + canvas.style.height = `${viewport.height}px`; - await pdfPage.render({ + context.clearRect(0, 0, canvas.width, canvas.height); + + const renderTask = pdfPage.render({ canvasContext: context, viewport, - }).promise; - } catch (error) { + }); + + pdfRenderTaskRef.current = renderTask; + await renderTask.promise; + + if (pdfRenderTaskRef.current === renderTask) { + pdfRenderTaskRef.current = null; + } + } catch (error: any) { + if (error?.name === 'RenderingCancelledException') { + return; + } + console.error('PDF render failed:', error); setPdfError( getErrorMessage( @@ -218,9 +487,11 @@ export default function PdfViewerPage() { ), ); } finally { - setIsPdfLoading(false); + if (renderNonce === pdfRenderNonceRef.current) { + setIsPdfLoading(false); + } } - }, []); + }, [getPdfDocument]); const loadViewerSession = useCallback(async (token: string) => { setIsPageLoading(true); @@ -235,7 +506,6 @@ export default function PdfViewerPage() { setSession(response.data); await loadFeedbackHistory(token); - await renderAssignedPage(token, response.data?.assignment?.pageNumber); } catch (error) { const message = getErrorMessage( error, @@ -246,7 +516,7 @@ export default function PdfViewerPage() { } finally { setIsPageLoading(false); } - }, [loadFeedbackHistory, renderAssignedPage]); + }, [loadFeedbackHistory]); useEffect(() => { if (!router.isReady) { @@ -264,6 +534,108 @@ export default function PdfViewerPage() { loadViewerSession(savedViewerToken); }, [loadViewerSession, router]); + useEffect(() => { + return () => { + resetPdfCache(); + }; + }, [resetPdfCache]); + + useEffect(() => { + setPdfZoom(1); + setPdfError(''); + setWordError(''); + resetPdfCache(); + }, [resetPdfCache, session?.assignment?.id, viewerToken]); + + useEffect(() => { + if ( + isPageLoading || + !viewerToken || + !session?.assignment?.id || + !session.assignment.pageNumber + ) { + return; + } + + renderAssignedPage( + viewerToken, + session.assignment.id, + session.assignment.pageNumber, + pdfZoom, + ); + }, [ + isPageLoading, + pdfZoom, + renderAssignedPage, + session?.assignment?.id, + session?.assignment?.pageNumber, + viewerToken, + ]); + + const handleZoomOut = () => { + setPdfZoom((currentZoom) => clampZoomLevel(currentZoom - ZOOM_STEP)); + setWordError(''); + }; + + const handleZoomIn = () => { + setPdfZoom((currentZoom) => clampZoomLevel(currentZoom + ZOOM_STEP)); + setWordError(''); + }; + + const handleResetZoom = () => { + setPdfZoom(1); + setWordError(''); + }; + + const handleExportWord = async () => { + if (!viewerToken || !session?.assignment?.id || !session.assignment.pageNumber) { + setWordError('Halaman PDF belum siap untuk diekspor ke Word.'); + return; + } + + setWordError(''); + + try { + setIsExportingWord(true); + const pdfDocument = await getPdfDocument(viewerToken, session.assignment.id); + const pdfPage = await pdfDocument.getPage(session.assignment.pageNumber); + const textContent = await pdfPage.getTextContent({ normalizeWhitespace: true }); + const previewImageDataUrl = await renderPdfPageToImageDataUrl(pdfPage); + const extractedLines = extractPdfTextLines(textContent.items || []); + const exportedAt = new Intl.DateTimeFormat('id-ID', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date()); + const wordHtml = buildWordDocumentHtml({ + title: assignmentTitle, + pageNumber: session.assignment.pageNumber, + viewerName: session.viewer.name, + ruleName: session.assignment.ruleName || 'Assignment aktif', + exportedAt, + previewImageDataUrl, + extractedLines, + }); + const fileName = `${sanitizeFileName(assignmentTitle)}-halaman-${session.assignment.pageNumber}.doc`; + + downloadBlob( + new Blob(['\ufeff', wordHtml], { + type: 'application/msword;charset=utf-8', + }), + fileName, + ); + } catch (error) { + console.error('Word export failed:', error); + setWordError( + getErrorMessage( + error, + 'File Word belum dapat dibuat. Coba lagi beberapa saat lagi.', + ), + ); + } finally { + setIsExportingWord(false); + } + }; + const handleExitViewer = async () => { sessionStorage.removeItem(VIEWER_SESSION_STORAGE_KEY); await router.push('/'); @@ -307,6 +679,16 @@ export default function PdfViewerPage() { } }; + const zoomPercentage = Math.round(pdfZoom * 100); + const isZoomOutDisabled = isPdfLoading || pdfZoom <= MIN_ZOOM_LEVEL; + const isZoomInDisabled = isPdfLoading || pdfZoom >= MAX_ZOOM_LEVEL; + const isWordExportDisabled = + isPdfLoading || + isExportingWord || + !viewerToken || + !session?.assignment?.id || + !session.assignment.pageNumber; + return ( <> @@ -438,16 +820,66 @@ export default function PdfViewerPage() {
-
+

Halaman PDF

Preview halaman yang diizinkan

+

+ Toolbar ini hanya berlaku untuk halaman {session.assignment.pageNumber}{' '} + yang diizinkan admin untuk akun Anda. +

-
- Halaman {session.assignment.pageNumber} +
+
+ Halaman {session.assignment.pageNumber} +
+
+ Zoom {zoomPercentage}% +
+ + + +
+
+ Export Word membuat file .doc dari halaman yang diizinkan. + Jika PDF berisi teks, isi Word akan lebih mudah diedit; jika PDF berupa scan, + file Word tetap menyertakan snapshot halaman. +
+ {isPdfLoading ? : null} {pdfError ? ( @@ -456,8 +888,14 @@ export default function PdfViewerPage() {
) : null} + {wordError ? ( +
+ {wordError} +
+ ) : null} +
-
+
event.preventDefault()}