add view de
This commit is contained in:
@@ -694,6 +694,13 @@ export interface ExamSubmission {
|
||||
gitPublishUrl?: string;
|
||||
}
|
||||
|
||||
function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer {
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out.buffer;
|
||||
}
|
||||
|
||||
export const apiExam = {
|
||||
list: async (): Promise<{ data: ExamRoomItem[] }> => {
|
||||
const res = await staffFetch('/exam-rooms');
|
||||
@@ -798,6 +805,38 @@ export const apiExam = {
|
||||
if (!res.ok) await parseError(res, 'Xóa gói đề thất bại');
|
||||
return res.json();
|
||||
},
|
||||
fetchPaperPdfBytes: async (examId: number, paperId: number): Promise<ArrayBuffer> => {
|
||||
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/view`);
|
||||
if (!res.ok) await parseError(res, 'Không mở được đề PDF');
|
||||
const json = await res.json() as { data: string };
|
||||
if (!json.data) throw new Error('Server không trả dữ liệu PDF');
|
||||
return decodeBase64ToArrayBuffer(json.data);
|
||||
},
|
||||
fetchPaperResourceBytes: async (examId: number, paperId: number, resourceId: number): Promise<ArrayBuffer> => {
|
||||
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/resources/${resourceId}/view`);
|
||||
if (!res.ok) await parseError(res, 'Không tải tài nguyên');
|
||||
const json = await res.json() as { data: string };
|
||||
if (!json.data) throw new Error('Server không trả dữ liệu file');
|
||||
return decodeBase64ToArrayBuffer(json.data);
|
||||
},
|
||||
downloadPaperFile: async (examId: number, paperId: number, opts?: { resourceId?: number; fileName?: string }) => {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.resourceId) {
|
||||
params.set('kind', 'resource');
|
||||
params.set('fileId', String(opts.resourceId));
|
||||
} else {
|
||||
params.set('kind', 'pdf');
|
||||
}
|
||||
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/download?${params}`);
|
||||
if (!res.ok) await parseError(res, 'Tải file thất bại');
|
||||
const buf = await res.arrayBuffer();
|
||||
const url = URL.createObjectURL(new Blob([buf]));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = opts?.fileName || 'de-thi.pdf';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
assignRandom: async (id: number) => {
|
||||
const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' });
|
||||
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');
|
||||
|
||||
44
management/src/components/ExamPaperPdfModal.tsx
Normal file
44
management/src/components/ExamPaperPdfModal.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { renderExamPdfPages } from '../utils/renderExamPdf';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
bytes: Uint8Array | null;
|
||||
loading?: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExamPaperPdfModal({ title, bytes, loading, onClose }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!bytes || !containerRef.current) return;
|
||||
let cancelled = false;
|
||||
setErr('');
|
||||
renderExamPdfPages(containerRef.current, bytes).catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setErr(e instanceof Error ? e.message : 'Không hiển thị được PDF');
|
||||
}
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [bytes]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay exam-pdf-viewer-overlay" onClick={onClose}>
|
||||
<div className="exam-pdf-viewer-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header exam-pdf-viewer-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button type="button" className="modal-close-btn" onClick={onClose} aria-label="Đóng">×</button>
|
||||
</div>
|
||||
<div className="exam-pdf-viewer-scroll" ref={containerRef}>
|
||||
{loading && <p className="exam-pdf-viewer-status">Đang tải đề...</p>}
|
||||
{err && <p className="login-error" style={{ padding: '1rem' }}>{err}</p>}
|
||||
{!loading && !bytes && !err && (
|
||||
<p className="exam-pdf-viewer-status">Không có dữ liệu PDF</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
apiExam,
|
||||
staffFetch,
|
||||
@@ -12,6 +12,7 @@ import { navigate, pushNav } from '../navigation';
|
||||
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
|
||||
import { openStaffChat } from '../chatEvents';
|
||||
import { AppPoolModal } from './AppPoolModal';
|
||||
import { ExamPaperPdfModal } from './ExamPaperPdfModal';
|
||||
import { NavHistoryBar } from './NavHistoryBar';
|
||||
import { StudentAvatar } from './StudentAvatar';
|
||||
import { StudentDetailModal } from './StudentDetailModal';
|
||||
@@ -83,6 +84,10 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions'>('roster');
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
|
||||
const [papersModalOpen, setPapersModalOpen] = useState(false);
|
||||
const [pdfViewer, setPdfViewer] = useState<{ title: string; bytes: Uint8Array | null } | null>(null);
|
||||
const [pdfLoading, setPdfLoading] = useState(false);
|
||||
const [inlineResource, setInlineResource] = useState<{ url: string; title: string; isImage: boolean } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -117,6 +122,25 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
|
||||
useEffect(() => { load().catch(console.error); }, [load]);
|
||||
|
||||
const closePdfViewer = useCallback(() => {
|
||||
setPdfViewer(null);
|
||||
setPdfLoading(false);
|
||||
}, []);
|
||||
|
||||
const closeInlineResource = useCallback(() => {
|
||||
setInlineResource((prev) => {
|
||||
if (prev?.url) URL.revokeObjectURL(prev.url);
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const inlineResourceRef = useRef(inlineResource);
|
||||
inlineResourceRef.current = inlineResource;
|
||||
|
||||
useEffect(() => () => {
|
||||
if (inlineResourceRef.current?.url) URL.revokeObjectURL(inlineResourceRef.current.url);
|
||||
}, []);
|
||||
|
||||
const fetchOnline = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiExam.fetchOnlineStudents(examId);
|
||||
@@ -237,6 +261,64 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const viewPaperPdf = async (paperId: number, title?: string) => {
|
||||
setErr('');
|
||||
const paperTitle = title || papers.find((p) => p.id === paperId)?.title || 'Đề thi';
|
||||
setPapersModalOpen(false);
|
||||
setPdfLoading(true);
|
||||
setPdfViewer({ title: paperTitle, bytes: null });
|
||||
try {
|
||||
const buf = await apiExam.fetchPaperPdfBytes(examId, paperId);
|
||||
setPdfViewer({ title: paperTitle, bytes: new Uint8Array(buf) });
|
||||
} catch (e: any) {
|
||||
closePdfViewer();
|
||||
setErr(e?.message || 'Không mở được đề PDF');
|
||||
} finally {
|
||||
setPdfLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const viewPaperResource = async (paperId: number, resourceId: number, fileName: string) => {
|
||||
setErr('');
|
||||
if (/\.pdf$/i.test(fileName)) {
|
||||
setPapersModalOpen(false);
|
||||
setPdfLoading(true);
|
||||
setPdfViewer({ title: fileName, bytes: null });
|
||||
try {
|
||||
const buf = await apiExam.fetchPaperResourceBytes(examId, paperId, resourceId);
|
||||
setPdfViewer({ title: fileName, bytes: new Uint8Array(buf) });
|
||||
} catch (e: any) {
|
||||
closePdfViewer();
|
||||
setErr(e?.message || 'Không mở được PDF');
|
||||
} finally {
|
||||
setPdfLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
closeInlineResource();
|
||||
try {
|
||||
const buf = await apiExam.fetchPaperResourceBytes(examId, paperId, resourceId);
|
||||
const isImage = /\.(png|jpe?g|gif|webp)$/i.test(fileName);
|
||||
const mime = /\.png$/i.test(fileName) ? 'image/png'
|
||||
: /\.jpe?g$/i.test(fileName) ? 'image/jpeg'
|
||||
: /\.gif$/i.test(fileName) ? 'image/gif'
|
||||
: 'image/webp';
|
||||
const url = URL.createObjectURL(new Blob([buf], { type: mime }));
|
||||
setInlineResource({ url, title: fileName, isImage });
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Không mở được tài nguyên');
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPaperResource = async (paperId: number, resourceId: number, fileName: string) => {
|
||||
setErr('');
|
||||
try {
|
||||
await apiExam.downloadPaperFile(examId, paperId, { resourceId, fileName });
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Không tải được tài nguyên');
|
||||
}
|
||||
};
|
||||
|
||||
const downloadAllSubs = async () => {
|
||||
setErr(''); setMsg('');
|
||||
setBundlingSubs(true);
|
||||
@@ -417,6 +499,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
{canCancel && (
|
||||
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
|
||||
)}
|
||||
{papers.length > 0 && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
|
||||
📄 Xem đề
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -610,9 +697,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
|
||||
{papers.map((p) => (
|
||||
<div key={p.id} style={{ border: '1px solid var(--border-light)', borderRadius: '8px', padding: '0.75rem', background: 'var(--bg-subtle)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<strong>{p.title}</strong>
|
||||
<span className="badge badge-muted">Gói đề</span>
|
||||
<div style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => viewPaperPdf(p.id)}>Xem PDF</button>
|
||||
<span className="badge badge-muted">Gói đề</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="config-card-desc" style={{ margin: '0.5rem 0 0', paddingLeft: '1.1rem' }}>
|
||||
<li>📄 main.pdf (đề chính)</li>
|
||||
@@ -620,7 +710,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<li style={{ color: 'var(--text-muted)' }}>Chưa có tài nguyên kèm</li>
|
||||
) : (
|
||||
(p.resources || []).map((r) => (
|
||||
<li key={r.id}>📎 {r.fileName}</li>
|
||||
<li key={r.id}>
|
||||
📎 {r.fileName}{' '}
|
||||
<button type="button" className="link-btn" onClick={() => downloadPaperResource(p.id, r.id, r.fileName)}>
|
||||
Tải
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
@@ -718,6 +813,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
+ Thêm sinh viên
|
||||
</button>
|
||||
)}
|
||||
{papers.length > 0 && (
|
||||
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
|
||||
📄 Xem đề ({papers.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
|
||||
@@ -820,7 +920,19 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<td style={{ color: isOnline ? 'var(--success)' : 'var(--text-muted)', fontWeight: 700 }}>
|
||||
{isOnline ? 'Online' : 'Offline'}
|
||||
</td>
|
||||
<td>{s.paperTitle || '—'}</td>
|
||||
<td>
|
||||
{s.paperTitle || '—'}
|
||||
{s.assignedPaperId && (
|
||||
<button
|
||||
type="button"
|
||||
className="link-btn"
|
||||
style={{ marginLeft: '0.35rem' }}
|
||||
onClick={() => viewPaperPdf(s.assignedPaperId!)}
|
||||
>
|
||||
Xem đề
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td>{s.paperSentAt ? fmtTime(s.paperSentAt) : s.paperScheduledAt ? `Hẹn ${fmtTime(s.paperScheduledAt)}` : '—'}</td>
|
||||
<td>{s.submitted ? <span className="badge badge-success">Đã nộp</span> : '—'}</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
@@ -853,6 +965,15 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
<div className="session-logs-panel">
|
||||
{submissions.length > 0 && (
|
||||
<div className="exam-submissions-toolbar">
|
||||
{papers.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setPapersModalOpen(true)}
|
||||
>
|
||||
📄 Xem đề phòng thi
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
@@ -939,6 +1060,81 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pdfViewer && (
|
||||
<ExamPaperPdfModal
|
||||
title={pdfViewer.title}
|
||||
bytes={pdfViewer.bytes}
|
||||
loading={pdfLoading}
|
||||
onClose={closePdfViewer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{inlineResource && (
|
||||
<div className="modal-overlay exam-pdf-viewer-overlay" onClick={closeInlineResource}>
|
||||
<div className="exam-pdf-viewer-modal exam-inline-resource-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header exam-pdf-viewer-header">
|
||||
<h2 className="modal-title">{inlineResource.title}</h2>
|
||||
<button type="button" className="modal-close-btn" onClick={closeInlineResource} aria-label="Đóng">×</button>
|
||||
</div>
|
||||
<div className="exam-inline-resource-body">
|
||||
{inlineResource.isImage ? (
|
||||
<img src={inlineResource.url} alt={inlineResource.title} className="exam-inline-resource-image" />
|
||||
) : (
|
||||
<iframe src={inlineResource.url} title={inlineResource.title} className="exam-pdf-viewer-frame" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{papersModalOpen && (
|
||||
<div className="modal-overlay" onClick={() => setPapersModalOpen(false)}>
|
||||
<div className="modal-container exam-papers-view-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<div>
|
||||
<h2 className="modal-title">Đề thi — {roomName}</h2>
|
||||
<p className="exam-student-modal-desc">{papers.length} gói đề trong phòng thi</p>
|
||||
</div>
|
||||
<button type="button" className="modal-close-btn" onClick={() => setPapersModalOpen(false)} aria-label="Đóng">×</button>
|
||||
</div>
|
||||
<div className="exam-papers-view-body">
|
||||
{papers.map((p) => (
|
||||
<div key={p.id} className="exam-paper-view-card">
|
||||
<div className="exam-paper-view-head">
|
||||
<strong>{p.title}</strong>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={(e) => { e.preventDefault(); void viewPaperPdf(p.id); }}>
|
||||
Xem PDF đề
|
||||
</button>
|
||||
</div>
|
||||
<ul className="exam-paper-view-resources">
|
||||
<li>📄 Đề chính (PDF)</li>
|
||||
{(p.resources || []).length === 0 ? (
|
||||
<li className="text-muted">Không có tài nguyên kèm</li>
|
||||
) : (
|
||||
(p.resources || []).map((r) => (
|
||||
<li key={r.id}>
|
||||
📎 {r.fileName}
|
||||
<span className="exam-paper-view-actions">
|
||||
{/\.(pdf|png|jpe?g|gif|webp|txt)$/i.test(r.fileName) && (
|
||||
<button type="button" className="link-btn" onClick={() => viewPaperResource(p.id, r.id, r.fileName)}>
|
||||
Xem
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="link-btn" onClick={() => downloadPaperResource(p.id, r.id, r.fileName)}>
|
||||
Tải
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedStudent && (
|
||||
<StudentDetailModal
|
||||
student={selectedStudent}
|
||||
|
||||
@@ -4122,6 +4122,131 @@ input:checked + .slider:before {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.exam-papers-view-modal {
|
||||
width: min(560px, 94vw);
|
||||
max-height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.exam-papers-view-body {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.exam-paper-view-card {
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.exam-paper-view-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.exam-paper-view-resources {
|
||||
margin: 0.65rem 0 0;
|
||||
padding-left: 1.1rem;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.exam-paper-view-resources li {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.exam-paper-view-actions {
|
||||
margin-left: 0.5rem;
|
||||
display: inline-flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.exam-pdf-viewer-overlay {
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.exam-pdf-viewer-modal {
|
||||
width: min(960px, 96vw);
|
||||
height: min(90vh, 900px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.exam-pdf-viewer-header {
|
||||
flex-shrink: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.exam-pdf-viewer-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: #525659;
|
||||
padding: 1rem 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.exam-pdf-viewer-status {
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
padding: 2rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.exam-pdf-page-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.exam-pdf-page {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.exam-pdf-viewer-frame {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: #525659;
|
||||
}
|
||||
|
||||
.exam-inline-resource-modal {
|
||||
height: min(85vh, 800px);
|
||||
}
|
||||
|
||||
.exam-inline-resource-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-subtle);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.exam-inline-resource-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.exam-send-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
15
management/src/pdfjs.d.ts
vendored
Normal file
15
management/src/pdfjs.d.ts
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
declare module 'pdfjs-dist/build/pdf' {
|
||||
export const GlobalWorkerOptions: { workerSrc: string };
|
||||
export function getDocument(src: { data: Uint8Array }): { promise: Promise<{
|
||||
numPages: number;
|
||||
getPage: (n: number) => Promise<{
|
||||
getViewport: (opts: { scale: number }) => { width: number; height: number };
|
||||
render: (opts: { canvasContext: CanvasRenderingContext2D; viewport: unknown }) => { promise: Promise<void> };
|
||||
}>;
|
||||
}> };
|
||||
}
|
||||
|
||||
declare module 'pdfjs-dist/build/pdf.worker.min.js?url' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
33
management/src/utils/renderExamPdf.ts
Normal file
33
management/src/utils/renderExamPdf.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import * as pdfjsLib from 'pdfjs-dist/build/pdf';
|
||||
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.js?url';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
|
||||
|
||||
export async function renderExamPdfPages(container: HTMLElement, bytes: Uint8Array) {
|
||||
container.innerHTML = '';
|
||||
const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
|
||||
const pad = 16;
|
||||
const width = container.clientWidth || container.parentElement?.clientWidth || 900;
|
||||
const maxWidth = Math.max(360, width - pad * 2);
|
||||
|
||||
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) {
|
||||
const page = await pdf.getPage(pageNum);
|
||||
const base = page.getViewport({ scale: 1 });
|
||||
const scale = Math.min(1.6, maxWidth / base.width);
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'exam-pdf-page-wrap';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.className = 'exam-pdf-page';
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
wrap.appendChild(canvas);
|
||||
container.appendChild(wrap);
|
||||
|
||||
await page.render({
|
||||
canvasContext: canvas.getContext('2d')!,
|
||||
viewport,
|
||||
}).promise;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user