hery
This commit is contained in:
parent
2130d23ff4
commit
a533e1ac32
@ -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, '<')
|
||||||
|
.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) => `<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,15 +344,41 @@ 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) {
|
||||||
|
pdfDocumentRef.current.destroy().catch((error: unknown) => {
|
||||||
|
console.error('Failed to release cached PDF document:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasRef.current.width = 0;
|
||||||
|
canvasRef.current.height = 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPdfDocument = useCallback(async (token: string, assignmentId: string) => {
|
||||||
const pdfjsLib = await loadPdfJsLibrary();
|
const pdfjsLib = await loadPdfJsLibrary();
|
||||||
|
|
||||||
if (!pdfjsLib) {
|
if (!pdfjsLib) {
|
||||||
@ -174,6 +387,11 @@ export default function PdfViewerPage() {
|
|||||||
|
|
||||||
pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL;
|
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: {
|
||||||
'x-viewer-session': token,
|
'x-viewer-session': token,
|
||||||
@ -181,35 +399,86 @@ export default function PdfViewerPage() {
|
|||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
});
|
});
|
||||||
|
|
||||||
const pdfDocument = await pdfjsLib.getDocument({ data: response.data }).promise;
|
cachedDocumentKeyRef.current = cacheKey;
|
||||||
const pdfPage = await pdfDocument.getPage(pageNumber);
|
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;
|
const canvas = canvasRef.current;
|
||||||
|
|
||||||
if (!canvas) {
|
if (!canvas) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let renderNonce = 0;
|
||||||
|
setPdfError('');
|
||||||
|
setIsPdfLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pdfDocument = await getPdfDocument(token, assignmentId);
|
||||||
|
const pdfPage = await pdfDocument.getPage(pageNumber);
|
||||||
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 {
|
||||||
|
if (renderNonce === pdfRenderNonceRef.current) {
|
||||||
setIsPdfLoading(false);
|
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,14 +820,64 @@ 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='flex flex-wrap items-center justify-end gap-2'>
|
||||||
<div className='rounded-full bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-700'>
|
<div className='rounded-full bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-700'>
|
||||||
Halaman {session.assignment.pageNumber}
|
Halaman {session.assignment.pageNumber}
|
||||||
</div>
|
</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 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>
|
</div>
|
||||||
|
|
||||||
{isPdfLoading ? <LoadingSpinner /> : null}
|
{isPdfLoading ? <LoadingSpinner /> : null}
|
||||||
@ -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()}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user