This commit is contained in:
Flatlogic Bot 2026-05-07 03:28:48 +00:00
parent 2130d23ff4
commit a533e1ac32

View File

@ -3,6 +3,10 @@ import {
mdiCheckCircleOutline, mdiCheckCircleOutline,
mdiCommentTextOutline, mdiCommentTextOutline,
mdiFilePdfBox, mdiFilePdfBox,
mdiFileWordOutline,
mdiMagnifyMinusOutline,
mdiMagnifyPlusOutline,
mdiRefresh,
mdiShieldLockOutline, mdiShieldLockOutline,
} from '@mdi/js'; } from '@mdi/js';
import axios from 'axios'; import axios from 'axios';
@ -22,6 +26,9 @@ import { useRouter } from 'next/router';
const VIEWER_SESSION_STORAGE_KEY = 'viewerAccessToken'; 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_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 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 { declare global {
interface Window { interface Window {
@ -93,6 +100,178 @@ function getStatusStyles(status: string) {
return 'bg-amber-100 text-amber-700 border-amber-200'; 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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) => `<p>${escapeHtml(line)}</p>`)
.join('')
: '<p><em>PDF ini kemungkinan berupa scan atau gambar, sehingga file Word berisi snapshot halaman untuk ditinjau.</em></p>';
return `<!DOCTYPE html>
<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word" xmlns="http://www.w3.org/TR/REC-html40">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(title)}</title>
<style>
body {
font-family: Arial, Helvetica, sans-serif;
color: #0f172a;
line-height: 1.6;
margin: 32px;
}
.meta {
margin: 0 0 8px;
}
.preview {
margin: 24px 0;
page-break-inside: avoid;
}
.preview img {
width: 100%;
max-width: 760px;
border: 1px solid #cbd5e1;
border-radius: 16px;
}
.section-title {
margin-top: 28px;
margin-bottom: 12px;
}
</style>
</head>
<body>
<h1>${escapeHtml(title)}</h1>
<p class="meta"><strong>Viewer:</strong> ${escapeHtml(viewerName)}</p>
<p class="meta"><strong>Halaman yang diizinkan:</strong> ${pageNumber}</p>
<p class="meta"><strong>Aturan akses:</strong> ${escapeHtml(ruleName)}</p>
<p class="meta"><strong>Diekspor:</strong> ${escapeHtml(exportedAt)}</p>
<div class="preview">
<h2 class="section-title">Snapshot halaman</h2>
<img src="${previewImageDataUrl}" alt="Preview halaman PDF" />
</div>
<div>
<h2 class="section-title">Isi teks halaman</h2>
${extractedTextHtml}
</div>
</body>
</html>`;
}
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() { function loadPdfJsLibrary() {
return new Promise<any>((resolve, reject) => { return new Promise<any>((resolve, reject) => {
@ -127,6 +306,11 @@ function loadPdfJsLibrary() {
export default function PdfViewerPage() { export default function PdfViewerPage() {
const router = useRouter(); const router = useRouter();
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const pdfContainerRef = useRef<HTMLDivElement | null>(null);
const pdfDocumentRef = useRef<any | null>(null);
const pdfRenderTaskRef = useRef<any | null>(null);
const pdfRenderNonceRef = useRef(0);
const cachedDocumentKeyRef = useRef('');
const [viewerToken, setViewerToken] = useState(''); const [viewerToken, setViewerToken] = useState('');
const [session, setSession] = useState<ViewerSession | null>(null); const [session, setSession] = useState<ViewerSession | null>(null);
const [feedbackItems, setFeedbackItems] = useState<ViewerFeedbackItem[]>([]); const [feedbackItems, setFeedbackItems] = useState<ViewerFeedbackItem[]>([]);
@ -134,8 +318,11 @@ export default function PdfViewerPage() {
const [isPageLoading, setIsPageLoading] = useState(true); const [isPageLoading, setIsPageLoading] = useState(true);
const [isPdfLoading, setIsPdfLoading] = useState(false); const [isPdfLoading, setIsPdfLoading] = useState(false);
const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false); const [isSubmittingFeedback, setIsSubmittingFeedback] = useState(false);
const [isExportingWord, setIsExportingWord] = useState(false);
const [pdfZoom, setPdfZoom] = useState(1);
const [pageError, setPageError] = useState(''); const [pageError, setPageError] = useState('');
const [pdfError, setPdfError] = useState(''); const [pdfError, setPdfError] = useState('');
const [wordError, setWordError] = useState('');
const [feedbackError, setFeedbackError] = useState(''); const [feedbackError, setFeedbackError] = useState('');
const [feedbackSuccess, setFeedbackSuccess] = useState(''); const [feedbackSuccess, setFeedbackSuccess] = useState('');
@ -157,22 +344,53 @@ export default function PdfViewerPage() {
setFeedbackItems(response.data || []); setFeedbackItems(response.data || []);
}, []); }, []);
const renderAssignedPage = useCallback(async (token: string, pageNumber: number) => { const resetPdfCache = useCallback(() => {
if (!pageNumber) { if (pdfRenderTaskRef.current?.cancel) {
return; try {
pdfRenderTaskRef.current.cancel();
} catch (error) {
console.error('Failed to cancel PDF render task:', error);
}
} }
setPdfError(''); pdfRenderTaskRef.current = null;
setIsPdfLoading(true); pdfRenderNonceRef.current += 1;
setIsPdfLoading(false);
try { if (pdfDocumentRef.current?.destroy) {
const pdfjsLib = await loadPdfJsLibrary(); pdfDocumentRef.current.destroy().catch((error: unknown) => {
console.error('Failed to release cached PDF document:', error);
});
}
if (!pdfjsLib) { pdfDocumentRef.current = null;
throw new Error('Viewer PDF tidak tersedia di browser ini.'); 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', { const response = await axios.get('/portal-access/document', {
headers: { headers: {
@ -181,35 +399,86 @@ export default function PdfViewerPage() {
responseType: 'arraybuffer', 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 pdfPage = await pdfDocument.getPage(pageNumber);
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
if (!context) { if (!context) {
throw new Error('Canvas viewer tidak tersedia.'); 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 initialViewport = pdfPage.getViewport({ scale: 1 });
const scale = Math.min(Math.max(parentWidth / initialViewport.width, 1), 2.2); const fitScale = Math.max(containerWidth / initialViewport.width, 0.35);
const viewport = pdfPage.getViewport({ scale }); const viewport = pdfPage.getViewport({
scale: Math.max(fitScale * clampZoomLevel(zoomLevel), 0.35),
});
canvas.width = viewport.width; canvas.width = viewport.width;
canvas.height = viewport.height; canvas.height = viewport.height;
canvas.style.width = '100%'; canvas.style.width = `${viewport.width}px`;
canvas.style.height = 'auto'; canvas.style.height = `${viewport.height}px`;
await pdfPage.render({ context.clearRect(0, 0, canvas.width, canvas.height);
const renderTask = pdfPage.render({
canvasContext: context, canvasContext: context,
viewport, 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); console.error('PDF render failed:', error);
setPdfError( setPdfError(
getErrorMessage( getErrorMessage(
@ -218,9 +487,11 @@ export default function PdfViewerPage() {
), ),
); );
} finally { } finally {
setIsPdfLoading(false); if (renderNonce === pdfRenderNonceRef.current) {
setIsPdfLoading(false);
}
} }
}, []); }, [getPdfDocument]);
const loadViewerSession = useCallback(async (token: string) => { const loadViewerSession = useCallback(async (token: string) => {
setIsPageLoading(true); setIsPageLoading(true);
@ -235,7 +506,6 @@ export default function PdfViewerPage() {
setSession(response.data); setSession(response.data);
await loadFeedbackHistory(token); await loadFeedbackHistory(token);
await renderAssignedPage(token, response.data?.assignment?.pageNumber);
} catch (error) { } catch (error) {
const message = getErrorMessage( const message = getErrorMessage(
error, error,
@ -246,7 +516,7 @@ export default function PdfViewerPage() {
} finally { } finally {
setIsPageLoading(false); setIsPageLoading(false);
} }
}, [loadFeedbackHistory, renderAssignedPage]); }, [loadFeedbackHistory]);
useEffect(() => { useEffect(() => {
if (!router.isReady) { if (!router.isReady) {
@ -264,6 +534,108 @@ export default function PdfViewerPage() {
loadViewerSession(savedViewerToken); loadViewerSession(savedViewerToken);
}, [loadViewerSession, router]); }, [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 () => { const handleExitViewer = async () => {
sessionStorage.removeItem(VIEWER_SESSION_STORAGE_KEY); sessionStorage.removeItem(VIEWER_SESSION_STORAGE_KEY);
await router.push('/'); 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 ( return (
<> <>
<Head> <Head>
@ -438,16 +820,66 @@ export default function PdfViewerPage() {
<div className='grid gap-6 xl:grid-cols-[1.15fr,0.85fr]'> <div className='grid gap-6 xl:grid-cols-[1.15fr,0.85fr]'>
<CardBox> <CardBox>
<div className='space-y-5'> <div className='space-y-5'>
<div className='flex flex-wrap items-center justify-between gap-3'> <div className='flex flex-wrap items-start justify-between gap-3'>
<div> <div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>Halaman PDF</p> <p className='text-xs font-semibold uppercase tracking-[0.24em] text-sky-700'>Halaman PDF</p>
<h3 className='mt-1 text-2xl font-bold text-slate-950'>Preview halaman yang diizinkan</h3> <h3 className='mt-1 text-2xl font-bold text-slate-950'>Preview halaman yang diizinkan</h3>
<p className='mt-2 text-sm leading-6 text-slate-500'>
Toolbar ini hanya berlaku untuk halaman {session.assignment.pageNumber}{' '}
yang diizinkan admin untuk akun Anda.
</p>
</div> </div>
<div className='rounded-full bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-700'> <div className='flex flex-wrap items-center justify-end gap-2'>
Halaman {session.assignment.pageNumber} <div className='rounded-full bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-700'>
Halaman {session.assignment.pageNumber}
</div>
<div className='rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-semibold text-slate-700'>
Zoom {zoomPercentage}%
</div>
<BaseButton
small
color='light'
outline
icon={mdiMagnifyMinusOutline}
label='Zoom Out'
onClick={handleZoomOut}
disabled={isZoomOutDisabled}
/>
<BaseButton
small
color='light'
outline
icon={mdiRefresh}
label='Reset 100%'
onClick={handleResetZoom}
disabled={isPdfLoading}
/>
<BaseButton
small
color='light'
outline
icon={mdiMagnifyPlusOutline}
label='Zoom In'
onClick={handleZoomIn}
disabled={isZoomInDisabled}
/>
<BaseButton
small
color='info'
icon={mdiFileWordOutline}
label={isExportingWord ? 'Menyiapkan Word...' : 'Convert ke Word'}
onClick={handleExportWord}
disabled={isWordExportDisabled}
/>
</div> </div>
</div> </div>
<div className='rounded-3xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm leading-6 text-slate-600'>
Export Word membuat file <strong>.doc</strong> dari halaman yang diizinkan.
Jika PDF berisi teks, isi Word akan lebih mudah diedit; jika PDF berupa scan,
file Word tetap menyertakan snapshot halaman.
</div>
{isPdfLoading ? <LoadingSpinner /> : null} {isPdfLoading ? <LoadingSpinner /> : null}
{pdfError ? ( {pdfError ? (
@ -456,8 +888,14 @@ export default function PdfViewerPage() {
</div> </div>
) : null} ) : null}
{wordError ? (
<div className='rounded-3xl border border-red-200 bg-red-50 p-4 text-sm text-red-700'>
{wordError}
</div>
) : null}
<div className='overflow-hidden rounded-[28px] border border-slate-200 bg-slate-50 p-3 shadow-inner'> <div className='overflow-hidden rounded-[28px] border border-slate-200 bg-slate-50 p-3 shadow-inner'>
<div className='overflow-auto rounded-[22px] bg-white p-3'> <div ref={pdfContainerRef} className='overflow-auto rounded-[22px] bg-white p-3'>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
onContextMenu={(event) => event.preventDefault()} onContextMenu={(event) => event.preventDefault()}