Merge mode-3 bank crop thumbnails
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
This commit is contained in:
commit
d47e0205c9
@ -11,6 +11,27 @@ 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();
|
||||||
@ -133,6 +154,7 @@ 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>
|
||||||
|
|||||||
68
src/pages/exam/bankCrops.ts
Normal file
68
src/pages/exam/bankCrops.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// 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