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
+

+
+
+
+
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}
+
-
+