Compare commits
No commits in common. "d47e0205c96a4249a23d0c5b4e984c2d71bae083" and "a80bdea140815847e227ea4f134c1291577504c7" have entirely different histories.
d47e0205c9
...
a80bdea140
@ -11,27 +11,6 @@ import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
|
|||||||
|
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
import type { BankQuestion, BankResponse } from '../../types/exam.types';
|
import type { BankQuestion, BankResponse } from '../../types/exam.types';
|
||||||
import { cropQuestion } from './bankCrops';
|
|
||||||
|
|
||||||
const QuestionCrop: React.FC<{ q: BankQuestion }> = ({ q }) => {
|
|
||||||
const [src, setSrc] = useState<string | null>(null);
|
|
||||||
const [done, setDone] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
cropQuestion({ templateId: q.template_id, page: q.page, bounds: q.bounds })
|
|
||||||
.then((d) => { if (alive) { setSrc(d); setDone(true); } })
|
|
||||||
.catch(() => { if (alive) setDone(true); });
|
|
||||||
return () => { alive = false; };
|
|
||||||
}, [q.template_id, q.page, q.bounds]);
|
|
||||||
if (src) {
|
|
||||||
return <Box component="img" src={src} alt="" loading="lazy" sx={{ width: '100%', maxHeight: 150, objectFit: 'cover', objectPosition: 'top', display: 'block', bgcolor: '#fff', borderBottom: 1, borderColor: 'divider' }} />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Box sx={{ height: 56, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: 'action.hover', borderBottom: 1, borderColor: 'divider', color: 'text.disabled', fontSize: 12 }}>
|
|
||||||
{done ? 'no preview' : <CircularProgress size={16} />}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const QuestionBankPage: React.FC = () => {
|
const QuestionBankPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -154,7 +133,6 @@ const QuestionBankPage: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<Card key={q.id} variant="outlined" sx={{ borderColor: on ? 'primary.main' : 'divider', borderWidth: on ? 2 : 1 }}>
|
<Card key={q.id} variant="outlined" sx={{ borderColor: on ? 'primary.main' : 'divider', borderWidth: on ? 2 : 1 }}>
|
||||||
<CardActionArea onClick={() => toggle(q.id)}>
|
<CardActionArea onClick={() => toggle(q.id)}>
|
||||||
<QuestionCrop q={q} />
|
|
||||||
<CardContent sx={{ pb: 1.5 }}>
|
<CardContent sx={{ pb: 1.5 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||||
<Typography variant="subtitle2" fontWeight={700}>Q{q.label ?? '?'}</Typography>
|
<Typography variant="subtitle2" fontWeight={700}>Q{q.label ?? '?'}</Typography>
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
// Client-side crop thumbnails for the question bank. A bank question carries `bounds` (x,y,w,h) in the
|
|
||||||
// 780px stacked-page canvas space and a `page` number; the source PDF renders at that same 780px width
|
|
||||||
// (pdfLoader), so a thumbnail is just the page image cropped to the bounds. Renders each template's PDF
|
|
||||||
// once (cached) and reuses it for all of that template's questions. No API/infra change.
|
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
|
||||||
import { loadPdfPageImages, PdfPageImage } from './setup/pdfLoader';
|
|
||||||
|
|
||||||
const renderCache = new Map<string, Promise<PdfPageImage[]>>();
|
|
||||||
const imageCache = new Map<string, Promise<HTMLImageElement>>();
|
|
||||||
|
|
||||||
function templateRender(templateId: string): Promise<PdfPageImage[]> {
|
|
||||||
let p = renderCache.get(templateId);
|
|
||||||
if (!p) {
|
|
||||||
p = examRepository.getTemplateSourcePdf(templateId).then((bytes) => loadPdfPageImages(bytes));
|
|
||||||
p.catch(() => renderCache.delete(templateId)); // let a failed render be retried
|
|
||||||
renderCache.set(templateId, p);
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
||||||
let p = imageCache.get(src);
|
|
||||||
if (!p) {
|
|
||||||
p = new Promise<HTMLImageElement>((resolve, reject) => {
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => resolve(img);
|
|
||||||
img.onerror = () => reject(new Error('image decode failed'));
|
|
||||||
img.src = src;
|
|
||||||
});
|
|
||||||
imageCache.set(src, p);
|
|
||||||
}
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CropInput {
|
|
||||||
templateId: string;
|
|
||||||
page: number | null;
|
|
||||||
bounds: Record<string, number> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns a PNG data-URL cropped to the question, or null if it can't be produced (no bounds/page/PDF).
|
|
||||||
export async function cropQuestion(input: CropInput, maxWidth = 320): Promise<string | null> {
|
|
||||||
const { templateId, page, bounds } = input;
|
|
||||||
if (!bounds || !page) return null;
|
|
||||||
const pages = await templateRender(templateId);
|
|
||||||
const pageImg = pages.find((pg) => pg.pageNumber === page);
|
|
||||||
if (!pageImg) return null;
|
|
||||||
|
|
||||||
// stacked y-offset of this page (bounds.y is absolute across the vertically-stacked pages)
|
|
||||||
let yTop = 0;
|
|
||||||
for (const pg of pages) { if (pg.pageNumber === page) break; yTop += pg.height; }
|
|
||||||
|
|
||||||
const sx = Math.max(0, Math.round(bounds.x ?? 0));
|
|
||||||
const sy = Math.max(0, Math.round((bounds.y ?? 0) - yTop));
|
|
||||||
const sw = Math.min(Math.round(bounds.w ?? pageImg.width), pageImg.width - sx);
|
|
||||||
const sh = Math.min(Math.round(bounds.h ?? 80), pageImg.height - sy);
|
|
||||||
if (sw <= 1 || sh <= 1) return null;
|
|
||||||
|
|
||||||
const img = await loadImage(pageImg.src);
|
|
||||||
const scale = Math.min(1, maxWidth / sw);
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = Math.max(1, Math.round(sw * scale));
|
|
||||||
canvas.height = Math.max(1, Math.round(sh * scale));
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
if (!ctx) return null;
|
|
||||||
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
|
||||||
return canvas.toDataURL('image/png');
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user