Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b459a6ca |
@@ -31,10 +31,7 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
|
||||
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
|
||||
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
|
||||
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
|
||||
vi.mock('./pages/exam', () => ({
|
||||
ExamDashboardPage: () => <div>Exam Marker</div>,
|
||||
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
|
||||
}));
|
||||
vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> }));
|
||||
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
||||
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
@@ -22,8 +22,6 @@ import {
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import ArchiveIcon from '@mui/icons-material/Archive';
|
||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { examRepository } from '../../services/exam/examRepository';
|
||||
@@ -36,51 +34,6 @@ const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> =
|
||||
archived: 'default',
|
||||
};
|
||||
|
||||
const VERSION_SEPARATOR = ' · ';
|
||||
const VERSION_RE = /(?:^|\s)[vV](\d+(?:\.\d+)*)$/;
|
||||
|
||||
type DialogMode = 'create' | 'edit' | 'duplicate';
|
||||
|
||||
type TemplateDialogState = {
|
||||
mode: DialogMode;
|
||||
template?: ExamTemplate;
|
||||
} | null;
|
||||
|
||||
function splitTemplateTitle(title: string): { name: string; version: string } {
|
||||
const parts = title.split(VERSION_SEPARATOR);
|
||||
const possibleVersion = parts[parts.length - 1]?.trim() ?? '';
|
||||
if (parts.length > 1 && VERSION_RE.test(possibleVersion)) {
|
||||
return { name: parts.slice(0, -1).join(VERSION_SEPARATOR).trim(), version: possibleVersion };
|
||||
}
|
||||
return { name: title, version: 'v1' };
|
||||
}
|
||||
|
||||
function composeTemplateTitle(name: string, version: string): string {
|
||||
const cleanName = name.trim();
|
||||
const cleanVersion = version.trim();
|
||||
return cleanVersion ? `${cleanName}${VERSION_SEPARATOR}${cleanVersion}` : cleanName;
|
||||
}
|
||||
|
||||
function nextVersionLabel(version: string): string {
|
||||
const match = version.trim().match(VERSION_RE);
|
||||
if (!match) return 'v2';
|
||||
const segments = match[1].split('.');
|
||||
const last = Number(segments[segments.length - 1]);
|
||||
segments[segments.length - 1] = Number.isFinite(last) ? String(last + 1) : '2';
|
||||
return `v${segments.join('.')}`;
|
||||
}
|
||||
|
||||
function paperKey(t: ExamTemplate): string {
|
||||
return t.exam_id ?? t.source_file_id ?? t.exam_code ?? t.subject ?? 'custom-paper';
|
||||
}
|
||||
|
||||
function paperLabel(t: ExamTemplate): string {
|
||||
if (t.exam_code) return t.exam_code;
|
||||
if (t.subject) return t.subject;
|
||||
if (t.source_file_id) return 'Uploaded paper';
|
||||
return 'Custom paper';
|
||||
}
|
||||
|
||||
const ExamDashboardPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { bootstrapData } = useAuth();
|
||||
@@ -90,9 +43,8 @@ const ExamDashboardPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [dialog, setDialog] = useState<TemplateDialogState>(null);
|
||||
const [templateName, setTemplateName] = useState('');
|
||||
const [version, setVersion] = useState('v1');
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -114,99 +66,33 @@ const ExamDashboardPage: React.FC = () => {
|
||||
void load();
|
||||
}, [load, instituteId]);
|
||||
|
||||
const groupedTemplates = useMemo(() => {
|
||||
const groups = new Map<string, { label: string; templates: ExamTemplate[] }>();
|
||||
templates.forEach((template) => {
|
||||
const key = paperKey(template);
|
||||
const existing = groups.get(key);
|
||||
if (existing) {
|
||||
existing.templates.push(template);
|
||||
} else {
|
||||
groups.set(key, { label: paperLabel(template), templates: [template] });
|
||||
}
|
||||
});
|
||||
return Array.from(groups.entries()).map(([key, group]) => ({ key, ...group }));
|
||||
}, [templates]);
|
||||
|
||||
const openCreate = () => {
|
||||
setTemplateName('New template');
|
||||
setVersion('v1');
|
||||
setSubject('');
|
||||
setDialog({ mode: 'create' });
|
||||
};
|
||||
|
||||
const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
const parsed = splitTemplateTitle(template.title);
|
||||
setTemplateName(parsed.name);
|
||||
setVersion(parsed.version);
|
||||
setSubject(template.subject ?? '');
|
||||
setDialog({ mode: 'edit', template });
|
||||
};
|
||||
|
||||
const openDuplicate = (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
const parsed = splitTemplateTitle(template.title);
|
||||
setTemplateName(parsed.name);
|
||||
setVersion(nextVersionLabel(parsed.version));
|
||||
setSubject(template.subject ?? '');
|
||||
setDialog({ mode: 'duplicate', template });
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
if (!saving) setDialog(null);
|
||||
};
|
||||
|
||||
const handleSaveDialog = async () => {
|
||||
if (!dialog || !templateName.trim()) return;
|
||||
const title = composeTemplateTitle(templateName, version);
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
if (dialog.mode === 'create') {
|
||||
const created = await examRepository.createTemplate({
|
||||
title,
|
||||
title: title.trim(),
|
||||
subject: subject.trim() || undefined,
|
||||
institute_id: instituteId,
|
||||
});
|
||||
setDialog(null);
|
||||
setCreateOpen(false);
|
||||
setTitle('');
|
||||
setSubject('');
|
||||
navigate(`/exam-marker/${created.id}/setup`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dialog.template) return;
|
||||
|
||||
if (dialog.mode === 'duplicate') {
|
||||
const created = await examRepository.duplicateTemplate(dialog.template.id, title);
|
||||
setTemplates((prev) => [created, ...prev]);
|
||||
setDialog(null);
|
||||
navigate(`/exam-marker/${created.id}/setup`);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await examRepository.updateTemplateMeta(dialog.template.id, {
|
||||
title,
|
||||
subject: subject.trim() || null,
|
||||
});
|
||||
setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||
setDialog(null);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode });
|
||||
logger.error('cc-exam-marker', 'Create template failed', { message: msg });
|
||||
setError(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||
const handleArchive = async (id: string, ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
const parsed = splitTemplateTitle(template.title);
|
||||
if (!window.confirm(`Archive ${parsed.name} ${parsed.version}? This hides it from the dashboard but keeps the work recoverable.`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await examRepository.archiveTemplate(template.id);
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== template.id));
|
||||
await examRepository.archiveTemplate(id);
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
||||
@@ -214,12 +100,6 @@ const ExamDashboardPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const dialogTitle = dialog?.mode === 'edit'
|
||||
? 'Rename template / edit version'
|
||||
: dialog?.mode === 'duplicate'
|
||||
? 'Duplicate as new version'
|
||||
: 'New exam template';
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||
<Stack spacing={4}>
|
||||
@@ -228,11 +108,11 @@ const ExamDashboardPage: React.FC = () => {
|
||||
<Typography variant="h3" component="h1" gutterBottom>
|
||||
Exam Marker
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}>
|
||||
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
|
||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
|
||||
Build a template for an exam paper, then run marking batches against your classes.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
New template
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -252,61 +132,29 @@ const ExamDashboardPage: React.FC = () => {
|
||||
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Create your first named template to start mapping an exam paper.
|
||||
Create your first template to start mapping an exam paper.
|
||||
</Typography>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
New template
|
||||
</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack spacing={3}>
|
||||
{groupedTemplates.map((group) => (
|
||||
<Box key={group.key}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||
<Typography variant="h6">{group.label}</Typography>
|
||||
<Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
|
||||
</Stack>
|
||||
<Grid container spacing={3}>
|
||||
{group.templates.map((t) => {
|
||||
const parsed = splitTemplateTitle(t.title);
|
||||
return (
|
||||
{templates.map((t) => (
|
||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 3,
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
transition: 'box-shadow 120ms',
|
||||
'&:hover': { boxShadow: 6 },
|
||||
}}
|
||||
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
|
||||
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
|
||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
|
||||
<Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
|
||||
</Box>
|
||||
<Stack direction="row" spacing={0.5}>
|
||||
<Tooltip title="Rename / edit version">
|
||||
<IconButton size="small" onClick={(e) => openEdit(t, e)} aria-label="rename template">
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Duplicate as new version">
|
||||
<IconButton size="small" onClick={(e) => openDuplicate(t, e)} aria-label="duplicate template">
|
||||
<ContentCopyIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
|
||||
<Tooltip title="Archive">
|
||||
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template">
|
||||
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
|
||||
<ArchiveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Box>
|
||||
{t.subject && (
|
||||
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
||||
@@ -317,53 +165,40 @@ const ExamDashboardPage: React.FC = () => {
|
||||
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Updated {new Date(t.updated_at).toLocaleDateString()}
|
||||
{new Date(t.updated_at).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Grid>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
||||
<DialogTitle>New exam template</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Template name"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
label="Title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
fullWidth
|
||||
autoFocus
|
||||
required
|
||||
helperText="User-facing name. Several templates can share the same paper."
|
||||
/>
|
||||
<TextField
|
||||
label="Version"
|
||||
value={version}
|
||||
onChange={(e) => setVersion(e.target.value)}
|
||||
fullWidth
|
||||
helperText="Stored in the template title until the API grows a dedicated version column."
|
||||
/>
|
||||
<TextField
|
||||
label="Paper / subject label"
|
||||
label="Subject"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
fullWidth
|
||||
disabled={dialog?.mode === 'duplicate'}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={closeDialog} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
|
||||
{saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
|
||||
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
||||
{saving ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
@@ -5,20 +5,21 @@ import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, S
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import SaveIcon from '@mui/icons-material/Save'
|
||||
import MouseIcon from '@mui/icons-material/Mouse'
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
|
||||
import '@tldraw/tldraw/tldraw.css'
|
||||
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
||||
import { Editor, Tldraw, createShapeId, defaultBindingUtils, TLShape } from '@tldraw/tldraw'
|
||||
import axios from 'axios'
|
||||
|
||||
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||
import { logger } from '../../../debugConfig'
|
||||
import { examRepository } from '../../../services/exam/examRepository'
|
||||
import type { ExamTemplateDetail } from '../../../types/exam.types'
|
||||
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||
import { PDF_PAGE_SHAPE_TYPE, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
||||
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
||||
import { ExamCanvasShapeModel, PAGE_WIDTH, type PdfPageLayout, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||
import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, PDF_PAGE_SHAPE_TYPE, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes'
|
||||
import { renderPdfBackdrop, replacePdfPageShapes } from './pdfLoader'
|
||||
|
||||
const TOOLS = [
|
||||
{ id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'inherit' as const },
|
||||
{ id: 'select', label: 'Select', tip: 'Move, resize, or delete user regions. The PDF backdrop is locked.', color: 'default' as const },
|
||||
{ id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' as const },
|
||||
{ id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const },
|
||||
{ id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const },
|
||||
@@ -26,21 +27,9 @@ const TOOLS = [
|
||||
{ id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number.', color: 'success' as const },
|
||||
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2].', color: 'success' as const },
|
||||
{ id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const },
|
||||
{ id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'inherit' as const },
|
||||
{ id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'default' as const },
|
||||
]
|
||||
|
||||
const PAGE_START_X = 260
|
||||
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
|
||||
|
||||
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
|
||||
let y = 0
|
||||
return pages.map((page) => {
|
||||
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
|
||||
y += page.height
|
||||
return geometry
|
||||
})
|
||||
}
|
||||
|
||||
function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
||||
@@ -54,27 +43,12 @@ function stripShapePrefix(id: string) {
|
||||
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
||||
}
|
||||
|
||||
function domainIdForShape(shape: ExamCanvasTLShape): string {
|
||||
const fromProps = shape.props.domainId
|
||||
if (isUuid(fromProps)) return fromProps
|
||||
const fromShapeId = stripShapePrefix(shape.id)
|
||||
return isUuid(fromShapeId) ? fromShapeId : newDomainId()
|
||||
}
|
||||
|
||||
function ensureDomainIds(editor: Editor) {
|
||||
const updates = editor.getCurrentPageShapes()
|
||||
.filter((shape): shape is ExamCanvasTLShape => !!shapeTypeToKind(shape.type))
|
||||
.filter((shape) => !isUuid(shape.props.domainId))
|
||||
.map((shape) => ({ id: shape.id, type: shape.type, props: { domainId: domainIdForShape(shape) } }))
|
||||
if (updates.length) editor.updateShapes(updates)
|
||||
}
|
||||
|
||||
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
||||
const kind = shapeTypeToKind(shape.type)
|
||||
if (!kind) return null
|
||||
const s = shape as ExamCanvasTLShape
|
||||
return {
|
||||
id: domainIdForShape(s),
|
||||
id: s.props.domainId || stripShapePrefix(s.id),
|
||||
kind,
|
||||
x: Number(s.x ?? 0),
|
||||
y: Number(s.y ?? 0),
|
||||
@@ -97,38 +71,23 @@ function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
||||
type: SHAPE_TYPES[m.kind],
|
||||
x: m.x,
|
||||
y: m.y,
|
||||
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId, domainId: m.id },
|
||||
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, domainId: m.id, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId },
|
||||
})))
|
||||
}
|
||||
|
||||
function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
|
||||
const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id)
|
||||
if (existing.length) editor.deleteShapes(existing)
|
||||
if (!pages.length) return
|
||||
const geometries = pageGeometryFromImages(pages)
|
||||
editor.createShapes(geometries.map((geometry) => {
|
||||
const page = pages[geometry.pageNumber - 1]
|
||||
return {
|
||||
id: createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber),
|
||||
type: PDF_PAGE_SHAPE_TYPE,
|
||||
x: geometry.x,
|
||||
y: geometry.y,
|
||||
isLocked: true,
|
||||
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
|
||||
} as any
|
||||
}))
|
||||
const ids = geometries.map((geometry) => createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber))
|
||||
try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ }
|
||||
function newDomainId() {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||
return `manual-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function seedGuide(editor: Editor) {
|
||||
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
|
||||
if (current.length) return
|
||||
editor.createShapes([
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end', domainId: newDomainId() } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 start' } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 end' } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', domainId: newDomainId(), label: 'Q1(a)', maxMarks: 3 } },
|
||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', domainId: newDomainId(), label: 'Response', responseForm: 'lines' } },
|
||||
])
|
||||
}
|
||||
|
||||
@@ -137,16 +96,39 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const theme = useTheme()
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
|
||||
const pageLayoutsRef = useRef<PdfPageLayout[]>([])
|
||||
const hydratingRef = useRef(false)
|
||||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [pdfLoading, setPdfLoading] = useState(false)
|
||||
const [pdfStatus, setPdfStatus] = useState<string>('PDF not loaded')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
const [activeTool, setActiveTool] = useState('select')
|
||||
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
||||
const [pdfError, setPdfError] = useState<string | null>(null)
|
||||
|
||||
const loadPdf = useCallback(async (id: string, editor: Editor) => {
|
||||
setPdfLoading(true)
|
||||
setPdfStatus('Loading PDF…')
|
||||
try {
|
||||
const result = await renderPdfBackdrop(id)
|
||||
hydratingRef.current = true
|
||||
replacePdfPageShapes(editor, result.pages)
|
||||
pageLayoutsRef.current = result.pageLayouts
|
||||
setPdfStatus(`${result.pages.length} PDF page${result.pages.length === 1 ? '' : 's'} loaded`)
|
||||
editor.zoomToFit()
|
||||
} catch (e) {
|
||||
pageLayoutsRef.current = []
|
||||
const msg = apiMessage(e).message
|
||||
setPdfStatus(`PDF unavailable: ${msg}`)
|
||||
logger.warn('cc-exam-marker', 'Template setup PDF backdrop failed', { templateId: id, message: msg })
|
||||
} finally {
|
||||
hydratingRef.current = false
|
||||
setPdfLoading(false)
|
||||
setDirty(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!templateId) return
|
||||
@@ -154,25 +136,12 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
try {
|
||||
const detail = await examRepository.getTemplate(templateId)
|
||||
setTemplate(detail)
|
||||
let pages: PdfPageImage[] = []
|
||||
setPdfStatus('loading')
|
||||
setPdfError(null)
|
||||
try {
|
||||
const bytes = await examRepository.getTemplateSourcePdf(templateId)
|
||||
pages = await loadPdfPageImages(bytes)
|
||||
setPdfStatus(pages.length ? 'ready' : 'missing')
|
||||
} catch (pdfErr) {
|
||||
const pdfMsg = apiMessage(pdfErr).message
|
||||
setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error')
|
||||
setPdfError(pdfMsg)
|
||||
logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg })
|
||||
}
|
||||
const geometries = pageGeometryFromImages(pages)
|
||||
pageGeometriesRef.current = geometries
|
||||
const editor = editorRef.current
|
||||
if (editor) {
|
||||
syncPdfPages(editor, pages)
|
||||
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
||||
hydratingRef.current = true
|
||||
loadShapes(editor, shapesFromTemplate(detail))
|
||||
hydratingRef.current = false
|
||||
await loadPdf(templateId, editor)
|
||||
}
|
||||
setDirty(false)
|
||||
} catch (e) {
|
||||
@@ -182,7 +151,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [templateId])
|
||||
}, [loadPdf, templateId])
|
||||
|
||||
useEffect(() => { void load() }, [load])
|
||||
|
||||
@@ -191,12 +160,13 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
if (!editor || !templateId || !template) return
|
||||
setSaving(true); setError(null); setConflict(null)
|
||||
try {
|
||||
ensureDomainIds(editor)
|
||||
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
||||
const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
|
||||
const shapes = editor.getCurrentPageShapes().filter((s) => s.type !== PDF_PAGE_SHAPE_TYPE).map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
||||
const payload = serializeCanvasShapes(template, shapes, pageLayoutsRef.current)
|
||||
const saved = await examRepository.replaceTemplate(templateId, payload)
|
||||
setTemplate(saved)
|
||||
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
|
||||
hydratingRef.current = true
|
||||
loadShapes(editor, shapesFromTemplate(saved))
|
||||
hydratingRef.current = false
|
||||
setDirty(false)
|
||||
} catch (e) {
|
||||
const msg = apiMessage(e)
|
||||
@@ -232,15 +202,24 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
|
||||
<Tldraw
|
||||
shapeUtils={examCanvasShapeUtils as any}
|
||||
bindingUtils={defaultBindingUtils}
|
||||
tools={examCanvasTools as any}
|
||||
hideUi
|
||||
inferDarkMode={theme.palette.mode === 'dark'}
|
||||
autoFocus
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor
|
||||
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||||
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
||||
editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' })
|
||||
editor.store.listen((entry) => {
|
||||
const changedPdfOnly = Object.values(entry.changes.added).every((record: any) => record?.type === PDF_PAGE_SHAPE_TYPE)
|
||||
if (!hydratingRef.current && !changedPdfOnly) setDirty(true)
|
||||
}, { scope: 'document' })
|
||||
if (template) {
|
||||
hydratingRef.current = true
|
||||
loadShapes(editor, shapesFromTemplate(template))
|
||||
hydratingRef.current = false
|
||||
if (templateId) void loadPdf(templateId, editor)
|
||||
} else seedGuide(editor)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -250,8 +229,9 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Exam Marker › Setup · draw boundaries, part boxes, and regions; Save persists a full replace.</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Exam Marker › Setup · real PDF backdrop is locked; drawn regions persist with PDF-derived page numbers.</Typography>
|
||||
</Box>
|
||||
<Chip size="small" icon={<PictureAsPdfIcon />} color={pdfStatus.includes('loaded') ? 'info' : pdfStatus.includes('unavailable') ? 'warning' : 'default'} label={pdfLoading ? 'Loading PDF…' : pdfStatus} />
|
||||
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||
<Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||
</Paper>
|
||||
@@ -263,10 +243,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
||||
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 420, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
1) Draw top and bottom Boundary lines for each main question. 2) Draw Part boxes inside the pair. 3) Draw Response/Context/metadata regions inside a Part. Save derives parent links by spatial containment and reloads from the API.
|
||||
</Typography>
|
||||
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
|
||||
PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'}
|
||||
1) Use the visible PDF pages as the paper coordinate system. 2) Draw top/bottom Boundary lines, Part boxes, then regions over the real paper. 3) Save derives page numbers from the PDF page under each shape and reloads them at the same canvas location.
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React from 'react'
|
||||
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
|
||||
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
||||
@@ -16,25 +15,24 @@ export const SHAPE_TYPES = {
|
||||
furniture: 'exam-region-furniture',
|
||||
} as const
|
||||
|
||||
export type ExamPdfPageTLShape = TLBaseBoxShape & {
|
||||
type: typeof PDF_PAGE_SHAPE_TYPE
|
||||
props: { w: number; h: number; src: string; pageNumber: number }
|
||||
}
|
||||
export type PdfPageTLShape = TLBaseBoxShape<typeof PDF_PAGE_SHAPE_TYPE, {
|
||||
w: number
|
||||
h: number
|
||||
src: string
|
||||
pageNumber: number
|
||||
}>
|
||||
|
||||
export type ExamCanvasTLShape = TLBaseBoxShape & {
|
||||
type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
|
||||
props: {
|
||||
export type ExamCanvasTLShape = TLBaseBoxShape<typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES], {
|
||||
w: number
|
||||
h: number
|
||||
label: string
|
||||
kind: ExamCanvasShapeKind
|
||||
domainId: string
|
||||
maxMarks?: number
|
||||
responseForm?: string
|
||||
contextType?: string
|
||||
questionId?: string | null
|
||||
domainId?: string
|
||||
}
|
||||
}
|
||||
}>
|
||||
|
||||
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: string }> = {
|
||||
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' },
|
||||
@@ -47,6 +45,13 @@ const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?
|
||||
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' },
|
||||
}
|
||||
|
||||
function makeDomainId() {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
|
||||
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
|
||||
)
|
||||
}
|
||||
|
||||
function renderShape(shape: ExamCanvasTLShape) {
|
||||
const kind = shape.props.kind
|
||||
const p = palette[kind] ?? palette.response
|
||||
@@ -71,29 +76,34 @@ function renderShape(shape: ExamCanvasTLShape) {
|
||||
|
||||
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||
const p = palette[kind]
|
||||
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
||||
return { w, h, label: p.label, kind, domainId: makeDomainId(), responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
||||
}
|
||||
|
||||
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) }
|
||||
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
||||
|
||||
class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
||||
class PdfPageUtil extends BaseBoxShapeUtil<PdfPageTLShape> {
|
||||
static override type = PDF_PAGE_SHAPE_TYPE
|
||||
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
|
||||
override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
|
||||
override canEdit() { return false }
|
||||
override component(shape: ExamPdfPageTLShape) {
|
||||
override canResize() { return false }
|
||||
override hideResizeHandles() { return true }
|
||||
override hideRotateHandle() { return true }
|
||||
override component(shape: PdfPageTLShape) {
|
||||
return (
|
||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
|
||||
<img src={shape.props.src} alt={'PDF page ' + shape.props.pageNumber} draggable={false} style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none', boxShadow: '0 2px 16px rgba(15,23,42,0.18)', background: '#fff' }} />
|
||||
<img
|
||||
alt={`PDF page ${shape.props.pageNumber}`}
|
||||
src={shape.props.src}
|
||||
draggable={false}
|
||||
style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', boxShadow: '0 12px 26px rgba(15,23,42,0.22)', background: '#fff' }}
|
||||
/>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
||||
}
|
||||
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
|
||||
|
||||
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = { w: T.number, h: T.number, label: T.string, kind: T.string, domainId: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string) }; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
||||
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
||||
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } }
|
||||
|
||||
class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary }
|
||||
class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
|
||||
@@ -107,10 +117,6 @@ class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.
|
||||
export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const
|
||||
export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const
|
||||
|
||||
export function isPdfPageShape(type: string): boolean {
|
||||
return type === PDF_PAGE_SHAPE_TYPE
|
||||
}
|
||||
|
||||
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
|
||||
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
|
||||
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
|
||||
|
||||
@@ -1,39 +1,75 @@
|
||||
import * as pdfjsLib from "pdfjs-dist"
|
||||
import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url"
|
||||
import type { Editor } from '@tldraw/tldraw'
|
||||
import * as pdfjsLib from 'pdfjs-dist'
|
||||
import pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs?url'
|
||||
|
||||
import { PAGE_WIDTH } from "../../../utils/exam-canvas/model"
|
||||
import { examRepository } from '../../../services/exam/examRepository'
|
||||
import { PAGE_GAP, PAGE_WIDTH, type PdfPageLayout } from '../../../utils/exam-canvas/model'
|
||||
import { PDF_PAGE_SHAPE_TYPE, type PdfPageTLShape } from './examCanvasShapes'
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
|
||||
|
||||
export interface PdfPageImage {
|
||||
pageNumber: number
|
||||
export interface PdfBackdropPage {
|
||||
page: number
|
||||
src: string
|
||||
width: number
|
||||
height: number
|
||||
layout: PdfPageLayout
|
||||
}
|
||||
|
||||
export async function loadPdfPageImages(pdfBytes: ArrayBuffer, targetWidth = PAGE_WIDTH): Promise<PdfPageImage[]> {
|
||||
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise
|
||||
const pages: PdfPageImage[] = []
|
||||
export interface PdfBackdropResult {
|
||||
pages: PdfBackdropPage[]
|
||||
pageLayouts: PdfPageLayout[]
|
||||
}
|
||||
|
||||
export async function renderPdfBackdrop(templateId: string): Promise<PdfBackdropResult> {
|
||||
const bytes = await examRepository.getTemplateSourcePdf(templateId)
|
||||
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(bytes) })
|
||||
const pdf = await loadingTask.promise
|
||||
const pages: PdfBackdropPage[] = []
|
||||
let y = 0
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||
const page = await pdf.getPage(pageNumber)
|
||||
const baseViewport = page.getViewport({ scale: 1 })
|
||||
const scale = targetWidth / baseViewport.width
|
||||
const viewport = page.getViewport({ scale })
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = Math.ceil(viewport.width)
|
||||
canvas.height = Math.ceil(viewport.height)
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("Unable to create PDF render canvas")
|
||||
await page.render({ canvasContext: context, viewport }).promise
|
||||
pages.push({
|
||||
pageNumber,
|
||||
src: canvas.toDataURL("image/png"),
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
})
|
||||
const nativeViewport = page.getViewport({ scale: 1 })
|
||||
const layoutScale = PAGE_WIDTH / nativeViewport.width
|
||||
const renderScale = layoutScale * Math.min(window.devicePixelRatio || 1, 2)
|
||||
const renderViewport = page.getViewport({ scale: renderScale })
|
||||
const canvas = document.createElement('canvas')
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) throw new Error('Could not create 2D canvas for PDF rendering')
|
||||
canvas.width = Math.ceil(renderViewport.width)
|
||||
canvas.height = Math.ceil(renderViewport.height)
|
||||
await page.render({ canvasContext: context, viewport: renderViewport }).promise
|
||||
|
||||
const layout: PdfPageLayout = {
|
||||
page: pageNumber,
|
||||
x: 0,
|
||||
y,
|
||||
w: PAGE_WIDTH,
|
||||
h: nativeViewport.height * layoutScale,
|
||||
}
|
||||
pages.push({ page: pageNumber, src: canvas.toDataURL('image/jpeg', 0.9), layout })
|
||||
y += layout.h + PAGE_GAP
|
||||
}
|
||||
|
||||
return pages
|
||||
await pdf.destroy()
|
||||
return { pages, pageLayouts: pages.map((p) => p.layout) }
|
||||
}
|
||||
|
||||
export function replacePdfPageShapes(editor: Editor, pages: PdfBackdropPage[]) {
|
||||
const old = editor.getCurrentPageShapes().filter((shape) => shape.type === PDF_PAGE_SHAPE_TYPE).map((shape) => shape.id)
|
||||
if (old.length) editor.deleteShapes(old)
|
||||
if (!pages.length) return
|
||||
|
||||
editor.createShapes(pages.map((page) => ({
|
||||
id: `shape:pdf-page-${page.page}` as PdfPageTLShape['id'],
|
||||
type: PDF_PAGE_SHAPE_TYPE,
|
||||
x: page.layout.x,
|
||||
y: page.layout.y,
|
||||
isLocked: true,
|
||||
props: {
|
||||
w: page.layout.w,
|
||||
h: page.layout.h,
|
||||
src: page.src,
|
||||
pageNumber: page.page,
|
||||
},
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -8,17 +8,12 @@
|
||||
import axios from 'axios';
|
||||
|
||||
import { API_BASE } from '../../config/apiConfig';
|
||||
import { logger } from '../../debugConfig';
|
||||
import { supabase } from '../../supabaseClient';
|
||||
import type {
|
||||
CreateTemplatePayload,
|
||||
ExamBoundary,
|
||||
ExamQuestion,
|
||||
ExamResponseArea,
|
||||
ExamTemplate,
|
||||
ExamTemplateDetail,
|
||||
TemplateReplacePayload,
|
||||
UpdateTemplateMetaPayload,
|
||||
} from '../../types/exam.types';
|
||||
|
||||
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||
@@ -31,86 +26,6 @@ async function authHeaders(): Promise<Record<string, string>> {
|
||||
return { Authorization: `Bearer ${session.access_token}` };
|
||||
}
|
||||
|
||||
function newUuid(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.floor(Math.random() * 16);
|
||||
const v = c === 'x' ? r : (r % 4) + 8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
|
||||
return {
|
||||
id: idMap?.get(q.id) ?? q.id,
|
||||
parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null,
|
||||
label: q.label,
|
||||
order: q.order,
|
||||
max_marks: q.max_marks,
|
||||
answer_type: q.answer_type,
|
||||
mcq_options: q.mcq_options,
|
||||
mark_scheme: q.mark_scheme ?? {},
|
||||
is_container: q.is_container,
|
||||
spec_ref: q.spec_ref,
|
||||
bounds: q.bounds ?? null,
|
||||
page: q.page ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, duplicate = false) {
|
||||
return {
|
||||
id: duplicate ? newUuid() : r.id,
|
||||
question_id: idMap?.get(r.question_id) ?? r.question_id,
|
||||
page: r.page,
|
||||
bounds: r.bounds,
|
||||
kind: r.kind,
|
||||
response_form: r.response_form,
|
||||
context_type: r.context_type ?? null,
|
||||
source: r.source,
|
||||
confirmed: r.confirmed,
|
||||
confidence: r.confidence,
|
||||
};
|
||||
}
|
||||
|
||||
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate = false) {
|
||||
return {
|
||||
id: duplicate ? newUuid() : b.id,
|
||||
question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null,
|
||||
label: b.label,
|
||||
page_index: b.page_index,
|
||||
y: b.y,
|
||||
bounds: b.bounds,
|
||||
source: b.source,
|
||||
confirmed: b.confirmed,
|
||||
};
|
||||
}
|
||||
|
||||
async function replaceTemplate(
|
||||
templateId: string,
|
||||
detail: ExamTemplateDetail,
|
||||
meta?: UpdateTemplateMetaPayload,
|
||||
duplicateIds = false,
|
||||
): Promise<ExamTemplateDetail> {
|
||||
const headers = await authHeaders();
|
||||
const idMap = new Map<string, string>();
|
||||
if (duplicateIds) {
|
||||
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
|
||||
}
|
||||
const res = await axios.put<ExamTemplateDetail>(
|
||||
`${EXAM_BASE}/templates/${templateId}`,
|
||||
{
|
||||
meta,
|
||||
questions: detail.questions.map((q) => questionPayload(q, idMap)),
|
||||
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
|
||||
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export const examRepository = {
|
||||
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
|
||||
const headers = await authHeaders();
|
||||
@@ -127,59 +42,24 @@ export const examRepository = {
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
|
||||
headers,
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
|
||||
const detail = await this.getTemplate(templateId);
|
||||
const created = await this.createTemplate({
|
||||
title,
|
||||
subject: detail.subject ?? undefined,
|
||||
exam_id: detail.exam_id ?? undefined,
|
||||
exam_code: detail.exam_code ?? undefined,
|
||||
source_file_id: detail.source_file_id ?? undefined,
|
||||
page_count: detail.page_count ?? undefined,
|
||||
institute_id: detail.institute_id,
|
||||
});
|
||||
try {
|
||||
return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true);
|
||||
} catch (error) {
|
||||
try {
|
||||
await this.archiveTemplate(created.id);
|
||||
} catch (archiveError) {
|
||||
logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', {
|
||||
templateId: created.id,
|
||||
message: archiveError instanceof Error ? archiveError.message : String(archiveError),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, { headers, responseType: 'arraybuffer' });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async archiveTemplate(templateId: string): Promise<void> {
|
||||
const headers = await authHeaders();
|
||||
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||
|
||||
+1
-16
@@ -31,13 +31,6 @@ export interface CreateTemplatePayload {
|
||||
institute_id?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTemplateMetaPayload {
|
||||
title?: string;
|
||||
subject?: string | null;
|
||||
page_count?: number | null;
|
||||
status?: ExamTemplateStatus;
|
||||
}
|
||||
|
||||
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||
export interface ExamQuestion {
|
||||
id: string;
|
||||
@@ -55,21 +48,13 @@ export interface ExamQuestion {
|
||||
page?: number | null;
|
||||
}
|
||||
|
||||
export type ExamResponseAreaKind =
|
||||
| 'response'
|
||||
| 'context'
|
||||
| 'question_number'
|
||||
| 'mark_area'
|
||||
| 'reference'
|
||||
| 'furniture';
|
||||
|
||||
export interface ExamResponseArea {
|
||||
id: string;
|
||||
question_id: string;
|
||||
template_id: string;
|
||||
page: number;
|
||||
bounds: Record<string, number>;
|
||||
kind: ExamResponseAreaKind;
|
||||
kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture';
|
||||
response_form: string | null;
|
||||
context_type?: string | null;
|
||||
source: 'manual' | 'ai';
|
||||
|
||||
@@ -1,65 +1,72 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ExamTemplateDetail } from '../../types/exam.types'
|
||||
import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model'
|
||||
import { serializeCanvasShapes, shapesFromTemplate, type PdfPageLayout } from './model'
|
||||
|
||||
const template: ExamTemplateDetail = {
|
||||
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
||||
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [],
|
||||
}
|
||||
|
||||
const ids = {
|
||||
top: '11111111-1111-4111-8111-111111111111',
|
||||
bottom: '22222222-2222-4222-8222-222222222222',
|
||||
main: '33333333-3333-4333-8333-333333333333',
|
||||
part: '44444444-4444-4444-8444-444444444444',
|
||||
response: '55555555-5555-4555-8555-555555555555',
|
||||
}
|
||||
|
||||
describe('exam setup canvas serialization', () => {
|
||||
it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => {
|
||||
const payload = serializeCanvasShapes(template, [
|
||||
{ id: 'b-top', kind: 'boundary', x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
|
||||
{ id: 'b-bottom', kind: 'boundary', x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
|
||||
{ id: 'part-1', kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 },
|
||||
{ id: 'resp-1', kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' },
|
||||
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
|
||||
{ id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
|
||||
{ id: ids.part, kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 },
|
||||
{ id: ids.response, kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' },
|
||||
])
|
||||
|
||||
const main = payload.questions.find((q) => q.is_container)
|
||||
const part = payload.questions.find((q) => !q.is_container)
|
||||
const part = payload.questions.find((q) => q.id === ids.part)
|
||||
expect(main?.label).toBe('Q1')
|
||||
expect(part?.parent_id).toBe(main?.id)
|
||||
expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 })
|
||||
expect(payload.response_areas[0]).toMatchObject({ question_id: part?.id, kind: 'response', response_form: 'lines' })
|
||||
expect(payload.response_areas[0]).toMatchObject({ id: ids.response, question_id: ids.part, kind: 'response', response_form: 'lines' })
|
||||
expect(payload.boundaries).toHaveLength(2)
|
||||
expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true)
|
||||
expect(payload.questions.every((q) => isUuid(q.id))).toBe(true)
|
||||
expect(payload.response_areas.every((r) => isUuid(r.id))).toBe(true)
|
||||
expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true)
|
||||
})
|
||||
|
||||
it('maps shapes to the visible PDF page geometry rather than a fixed page height', () => {
|
||||
const pages = [
|
||||
{ pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 },
|
||||
{ pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 },
|
||||
it('uses real PDF page layouts to save visual page numbers', () => {
|
||||
const layouts: PdfPageLayout[] = [
|
||||
{ page: 1, x: 0, y: 0, w: 780, h: 1000 },
|
||||
{ page: 2, x: 0, y: 1040, w: 780, h: 980 },
|
||||
]
|
||||
expect(pageForY(1050, pages)).toBe(2)
|
||||
const payload = serializeCanvasShapes(template, [
|
||||
{ id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' },
|
||||
{ id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' },
|
||||
{ id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' },
|
||||
{ id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 },
|
||||
], pages)
|
||||
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
||||
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
||||
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 1060, w: 700, h: 8, label: 'Q2 start' },
|
||||
{ id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 1800, w: 700, h: 8, label: 'Q2 end' },
|
||||
{ id: ids.part, kind: 'part', x: 100, y: 1120, w: 400, h: 220, label: 'Q2(a)', maxMarks: 3 },
|
||||
{ id: ids.response, kind: 'response', x: 130, y: 1250, w: 300, h: 90, responseForm: 'lines' },
|
||||
], layouts)
|
||||
|
||||
expect(payload.boundaries.map((b) => b.page_index)).toEqual([1, 1])
|
||||
expect(payload.questions.find((q) => q.id === ids.part)?.page).toBe(2)
|
||||
expect(payload.response_areas[0].page).toBe(2)
|
||||
expect(payload.meta?.page_count).toBe(2)
|
||||
})
|
||||
|
||||
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
|
||||
const shapes = shapesFromTemplate({
|
||||
...template,
|
||||
questions: [
|
||||
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null },
|
||||
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 },
|
||||
{ id: ids.main, template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null },
|
||||
{ id: ids.part, template_id: 'tpl-1', parent_id: ids.main, label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 },
|
||||
],
|
||||
response_areas: [
|
||||
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null },
|
||||
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null },
|
||||
{ id: ids.response, question_id: ids.part, template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null },
|
||||
{ id: '66666666-6666-4666-8666-666666666666', question_id: ids.part, template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null },
|
||||
],
|
||||
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
|
||||
boundaries: [{ id: ids.top, template_id: 'tpl-1', question_id: ids.main, label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
|
||||
})
|
||||
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
||||
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
|
||||
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: ids.part, x: 1, y: 2, w: 3, h: 4 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
|
||||
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
|
||||
|
||||
export const PAGE_HEIGHT = 1100
|
||||
export const PAGE_WIDTH = 780
|
||||
export const PAGE_GAP = 0
|
||||
|
||||
export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number }
|
||||
export const DEFAULT_PAGE_HEIGHT = 1100
|
||||
export const PAGE_GAP = 40
|
||||
// Back-compat alias for S4-9a callers/tests. Real PDF pages use PdfPageLayout heights.
|
||||
export const PAGE_HEIGHT = DEFAULT_PAGE_HEIGHT + PAGE_GAP
|
||||
|
||||
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
||||
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
||||
|
||||
export interface CanvasBounds { x: number; y: number; w: number; h: number }
|
||||
export interface PdfPageLayout { page: number; x: number; y: number; w: number; h: number }
|
||||
|
||||
export interface ExamCanvasShapeModel {
|
||||
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
|
||||
id: string
|
||||
kind: ExamCanvasShapeKind
|
||||
x: number
|
||||
@@ -28,41 +27,29 @@ export interface ExamCanvasShapeModel {
|
||||
questionId?: string | null
|
||||
}
|
||||
|
||||
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
|
||||
if (pages?.length) {
|
||||
const hit = pages.find((page) => y >= page.y && y <= page.y + page.h)
|
||||
if (hit) return hit.pageNumber
|
||||
const nearest = pages.reduce((best, page) => {
|
||||
const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h)))
|
||||
return dy < best.dy ? { page, dy } : best
|
||||
}, { page: pages[0], dy: Number.POSITIVE_INFINITY })
|
||||
return nearest.page.pageNumber
|
||||
}
|
||||
return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
|
||||
export function fallbackPageLayouts(pageCount = 4): PdfPageLayout[] {
|
||||
return Array.from({ length: Math.max(pageCount, 1) }, (_, i) => ({
|
||||
page: i + 1,
|
||||
x: 0,
|
||||
y: i * PAGE_HEIGHT,
|
||||
w: PAGE_WIDTH,
|
||||
h: DEFAULT_PAGE_HEIGHT,
|
||||
}))
|
||||
}
|
||||
|
||||
export function pageTop(page: number, pages?: CanvasPageGeometry[]): number {
|
||||
const hit = pages?.find((p) => p.pageNumber === page)
|
||||
return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP))
|
||||
export function pageForY(y: number, layouts?: PdfPageLayout[]): number {
|
||||
if (!layouts?.length) return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
|
||||
const hit = layouts.find((layout) => y >= layout.y && y <= layout.y + layout.h)
|
||||
if (hit) return hit.page
|
||||
const nearest = layouts.reduce((best, layout) => {
|
||||
const dist = y < layout.y ? layout.y - y : y - (layout.y + layout.h)
|
||||
return dist < best.dist ? { page: layout.page, dist } : best
|
||||
}, { page: layouts[0].page, dist: Number.POSITIVE_INFINITY })
|
||||
return nearest.page
|
||||
}
|
||||
|
||||
function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): number {
|
||||
return pageForY(shape.y + shape.h / 2, pages)
|
||||
}
|
||||
|
||||
export function isUuid(value: string | null | undefined): value is string {
|
||||
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||
}
|
||||
|
||||
export function newDomainId(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.floor(Math.random() * 16)
|
||||
const v = c === 'x' ? r : (r % 4) + 8
|
||||
return v.toString(16)
|
||||
})
|
||||
export function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, layouts?: PdfPageLayout[]): number {
|
||||
return pageForY(shape.y + shape.h / 2, layouts)
|
||||
}
|
||||
|
||||
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
|
||||
@@ -84,10 +71,21 @@ function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, s
|
||||
return cy >= minY && cy <= maxY
|
||||
}
|
||||
|
||||
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload {
|
||||
function uuid(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
|
||||
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
|
||||
)
|
||||
}
|
||||
|
||||
function stableUuid(id?: string | null): string {
|
||||
return id && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) ? id : uuid()
|
||||
}
|
||||
|
||||
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pageLayouts?: PdfPageLayout[]): TemplateReplacePayload {
|
||||
const orderedBoundaries = shapes
|
||||
.filter((s) => s.kind === 'boundary')
|
||||
.sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y))
|
||||
.sort((a, b) => (pageForShape(a, pageLayouts) - pageForShape(b, pageLayouts)) || (a.y - b.y))
|
||||
const parts = shapes.filter((s) => s.kind === 'part')
|
||||
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
|
||||
|
||||
@@ -100,51 +98,52 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
||||
const bottom = orderedBoundaries[i + 1]
|
||||
if (!top || !bottom) break
|
||||
const qNum = bands.length + 1
|
||||
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
|
||||
const questionId = stableUuid(top.questionId || bottom.questionId)
|
||||
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
|
||||
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
|
||||
bands.push({ questionId, top, bottom })
|
||||
for (const b of [top, bottom]) {
|
||||
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
|
||||
boundaries.push({ id: stableUuid(b.id), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pageLayouts) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
|
||||
}
|
||||
}
|
||||
|
||||
const partQuestionIds = new Map<string, string>()
|
||||
parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => {
|
||||
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
||||
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
|
||||
const qid = stableUuid(part.questionId || part.id)
|
||||
partQuestionIds.set(part.id, qid)
|
||||
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages) })
|
||||
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pageLayouts) })
|
||||
})
|
||||
|
||||
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||
for (const region of regions) {
|
||||
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
||||
const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
|
||||
const regionPage = pageForShape(region, pageLayouts)
|
||||
const fallbackPart = parts.find((part) => pageForShape(part, pageLayouts) === regionPage) ?? parts[0]
|
||||
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
||||
if (!questionId) continue
|
||||
const kind = region.kind as ExamCanvasRegionKind
|
||||
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
|
||||
response_areas.push({ id: stableUuid(region.id), question_id: questionId, page: regionPage, bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
|
||||
}
|
||||
|
||||
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
|
||||
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: pageLayouts?.length ?? template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
|
||||
}
|
||||
|
||||
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
||||
export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeModel[] {
|
||||
const shapes: ExamCanvasShapeModel[] = []
|
||||
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
||||
for (const b of detail.boundaries ?? []) {
|
||||
const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
|
||||
shapes.push({ id: b.id, kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
|
||||
shapes.push({ id: stableUuid(b.id), kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
|
||||
}
|
||||
for (const q of detail.questions ?? []) {
|
||||
if (q.is_container || !q.bounds) continue
|
||||
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
|
||||
shapes.push({ id: stableUuid(q.id), kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
|
||||
}
|
||||
for (const r of detail.response_areas ?? []) {
|
||||
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
|
||||
const bb = r.bounds ?? { x: 100, y: (r.page - 1) * PAGE_HEIGHT + 360, w: 360, h: 120 }
|
||||
const q = questions.get(r.question_id)
|
||||
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
|
||||
shapes.push({ id: stableUuid(r.id), kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? (r.page - 1) * PAGE_HEIGHT + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
|
||||
}
|
||||
return shapes
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user