Compare commits

..
Author SHA1 Message Date
kcar aa2f35e467 feat(exam): render template PDFs behind setup canvas 2026-06-07 03:56:09 +01:00
CC WorkerandClaude Sonnet 4.6 61a189a7a2 fix: tldraw user prefs colorScheme and indicator method for shape utils
app-ci-deploy / test-build-deploy (push) Has been cancelled
- updateUserPreferences: isDarkMode → colorScheme ('dark'|'light') per
  tldraw 3.6.1 TLUserPreferences type (isDarkMode is read-only computed)
- BaseBoxShapeUtil subclasses: add required indicator() method returning
  bounding rect; fixes non-abstract class missing abstract member error

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 02:30:57 +00:00
CC WorkerandClaude Sonnet 4.6 ab6f0b09d7 fix: remove duplicate bindingUtils causing tldraw arrow double-registration
app-ci-deploy / test-build-deploy (push) Has been cancelled
Tldraw 3.6.1 Tldraw component already registers defaultBindingUtils
internally; passing them explicitly via the bindingUtils prop caused
"Binding type 'arrow' is defined more than once" runtime crash.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 02:21:32 +00:00
CC WorkerandClaude Opus 4.8 16ae3aa089 fix(exam): setup toolbar crash — MUI Button color 'default' is invalid in v5
app-ci-deploy / test-build-deploy (push) Has been cancelled
Select + Furniture tool buttons used color='default', which MUI v5 resolves to
theme.palette.default (undefined) -> TypeError reading 'main'/'dark' at render,
tripping the ErrorBoundary ('Template setup canvas crashed'). Use 'inherit'.

Caught by live browser-verify t_11f5a049; model-level tests never rendered the toolbar.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 01:03:08 +00:00
CC Worker 496ec2cbf9 merge: ExamCanvas core setup page /exam-marker/:templateId/setup (S4-9a)
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 00:54:55 +00:00
kcar dea3275f23 [verified] Add exam template setup canvas 2026-06-07 01:27:44 +01:00
CC Worker f067db3eb8 Merge remote-tracking branch 'origin/agent/s4-8-2-template-versioning'
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 00:12:25 +00:00
kcar fdbc19cf0d feat(exam): version templates on dashboard 2026-06-07 00:33:06 +01:00
9 changed files with 605 additions and 327 deletions
+4 -1
View File
@@ -31,7 +31,10 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> })); vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> })); vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> })); vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> })); vi.mock('./pages/exam', () => ({
ExamDashboardPage: () => <div>Exam Marker</div>,
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
}));
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> })); vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> })); vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> })); vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
+228 -63
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
@@ -22,6 +22,8 @@ import {
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import ArchiveIcon from '@mui/icons-material/Archive'; import ArchiveIcon from '@mui/icons-material/Archive';
import AssignmentIcon from '@mui/icons-material/Assignment'; 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 { useAuth } from '../../contexts/AuthContext';
import { examRepository } from '../../services/exam/examRepository'; import { examRepository } from '../../services/exam/examRepository';
@@ -34,6 +36,51 @@ const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> =
archived: 'default', 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 ExamDashboardPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { bootstrapData } = useAuth(); const { bootstrapData } = useAuth();
@@ -43,8 +90,9 @@ const ExamDashboardPage: React.FC = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false); const [dialog, setDialog] = useState<TemplateDialogState>(null);
const [title, setTitle] = useState(''); const [templateName, setTemplateName] = useState('');
const [version, setVersion] = useState('v1');
const [subject, setSubject] = useState(''); const [subject, setSubject] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -66,33 +114,99 @@ const ExamDashboardPage: React.FC = () => {
void load(); void load();
}, [load, instituteId]); }, [load, instituteId]);
const handleCreate = async () => { const groupedTemplates = useMemo(() => {
if (!title.trim()) return; 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);
setSaving(true); setSaving(true);
try { try {
const created = await examRepository.createTemplate({ if (dialog.mode === 'create') {
title: title.trim(), const created = await examRepository.createTemplate({
subject: subject.trim() || undefined, title,
institute_id: instituteId, subject: subject.trim() || undefined,
institute_id: instituteId,
});
setDialog(null);
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,
}); });
setCreateOpen(false); setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setTitle(''); setDialog(null);
setSubject('');
navigate(`/exam-marker/${created.id}/setup`);
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Create template failed', { message: msg }); logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode });
setError(msg); setError(msg);
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
const handleArchive = async (id: string, ev: React.MouseEvent) => { const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => {
ev.stopPropagation(); 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 { try {
await examRepository.archiveTemplate(id); await examRepository.archiveTemplate(template.id);
setTemplates((prev) => prev.filter((t) => t.id !== id)); setTemplates((prev) => prev.filter((t) => t.id !== template.id));
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Archive failed', { message: msg }); logger.error('cc-exam-marker', 'Archive failed', { message: msg });
@@ -100,6 +214,12 @@ const ExamDashboardPage: React.FC = () => {
} }
}; };
const dialogTitle = dialog?.mode === 'edit'
? 'Rename template / edit version'
: dialog?.mode === 'duplicate'
? 'Duplicate as new version'
: 'New exam template';
return ( return (
<Container maxWidth="lg" sx={{ py: 6 }}> <Container maxWidth="lg" sx={{ py: 6 }}>
<Stack spacing={4}> <Stack spacing={4}>
@@ -108,11 +228,11 @@ const ExamDashboardPage: React.FC = () => {
<Typography variant="h3" component="h1" gutterBottom> <Typography variant="h3" component="h1" gutterBottom>
Exam Marker Exam Marker
</Typography> </Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}> <Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}>
Build a template for an exam paper, then run marking batches against your classes. Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
</Typography> </Typography>
</Box> </Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}> <Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
New template New template
</Button> </Button>
</Box> </Box>
@@ -132,73 +252,118 @@ const ExamDashboardPage: React.FC = () => {
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} /> <AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography variant="h6" gutterBottom>No exam templates yet</Typography> <Typography variant="h6" gutterBottom>No exam templates yet</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Create your first template to start mapping an exam paper. Create your first named template to start mapping an exam paper.
</Typography> </Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}> <Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
New template New template
</Button> </Button>
</Paper> </Paper>
) : ( ) : (
<Grid container spacing={3}> <Stack spacing={3}>
{templates.map((t) => ( {groupedTemplates.map((group) => (
<Grid item xs={12} sm={6} md={4} key={t.id}> <Box key={group.key}>
<Paper <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
elevation={2} <Typography variant="h6">{group.label}</Typography>
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1, <Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }} </Stack>
onClick={() => navigate(`/exam-marker/${t.id}/setup`)} <Grid container spacing={3}>
> {group.templates.map((t) => {
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}> const parsed = splitTemplateTitle(t.title);
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography> return (
<Tooltip title="Archive"> <Grid item xs={12} sm={6} md={4} key={t.id}>
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template"> <Paper
<ArchiveIcon fontSize="small" /> elevation={2}
</IconButton> sx={{
</Tooltip> p: 3,
</Box> height: '100%',
{t.subject && ( cursor: 'pointer',
<Typography variant="body2" color="text.secondary">{t.subject}</Typography> display: 'flex',
)} flexDirection: 'column',
{t.exam_code && ( gap: 1,
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography> transition: 'box-shadow 120ms',
)} '&:hover': { boxShadow: 6 },
<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" /> onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
<Typography variant="caption" color="text.secondary"> >
{new Date(t.updated_at).toLocaleDateString()} <Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
</Typography> <Box sx={{ minWidth: 0 }}>
</Box> <Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
</Paper> <Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
</Grid> </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>
<Tooltip title="Archive">
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template">
<ArchiveIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box>
{t.subject && (
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
)}
{t.exam_code && (
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
)}
<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()}
</Typography>
</Box>
</Paper>
</Grid>
);
})}
</Grid>
</Box>
))} ))}
</Grid> </Stack>
)} )}
</Stack> </Stack>
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm"> <Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
<DialogTitle>New exam template</DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent> <DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}> <Stack spacing={2} sx={{ mt: 1 }}>
<TextField <TextField
label="Title" label="Template name"
value={title} value={templateName}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTemplateName(e.target.value)}
fullWidth fullWidth
autoFocus autoFocus
required required
helperText="User-facing name. Several templates can share the same paper."
/> />
<TextField <TextField
label="Subject" 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"
value={subject} value={subject}
onChange={(e) => setSubject(e.target.value)} onChange={(e) => setSubject(e.target.value)}
fullWidth fullWidth
disabled={dialog?.mode === 'duplicate'}
/> />
</Stack> </Stack>
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button> <Button onClick={closeDialog} disabled={saving}>Cancel</Button>
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}> <Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
{saving ? 'Creating…' : 'Create'} {saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
+90 -67
View File
@@ -5,21 +5,20 @@ import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, S
import ArrowBackIcon from '@mui/icons-material/ArrowBack' import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import SaveIcon from '@mui/icons-material/Save' import SaveIcon from '@mui/icons-material/Save'
import MouseIcon from '@mui/icons-material/Mouse' import MouseIcon from '@mui/icons-material/Mouse'
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
import '@tldraw/tldraw/tldraw.css' import '@tldraw/tldraw/tldraw.css'
import { Editor, Tldraw, createShapeId, defaultBindingUtils, TLShape } from '@tldraw/tldraw' import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
import axios from 'axios' import axios from 'axios'
import { ErrorBoundary } from '../../../components/ErrorBoundary' import { ErrorBoundary } from '../../../components/ErrorBoundary'
import { logger } from '../../../debugConfig' import { logger } from '../../../debugConfig'
import { examRepository } from '../../../services/exam/examRepository' import { examRepository } from '../../../services/exam/examRepository'
import type { ExamTemplateDetail } from '../../../types/exam.types' import type { ExamTemplateDetail } from '../../../types/exam.types'
import { ExamCanvasShapeModel, PAGE_WIDTH, type PdfPageLayout, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, PDF_PAGE_SHAPE_TYPE, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes' import { PDF_PAGE_SHAPE_TYPE, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
import { renderPdfBackdrop, replacePdfPageShapes } from './pdfLoader' import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
const TOOLS = [ const TOOLS = [
{ id: 'select', label: 'Select', tip: 'Move, resize, or delete user regions. The PDF backdrop is locked.', color: 'default' as const }, { id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'inherit' 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.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.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 }, { id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const },
@@ -27,9 +26,21 @@ const TOOLS = [
{ id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number.', color: 'success' as const }, { 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.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.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: 'default' as const }, { id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'inherit' 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 } { function apiMessage(err: unknown): { message: string; conflict: boolean } {
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
const detail = (err.response?.data as { detail?: string } | undefined)?.detail const detail = (err.response?.data as { detail?: string } | undefined)?.detail
@@ -43,12 +54,27 @@ function stripShapePrefix(id: string) {
return id.startsWith('shape:') ? id.slice('shape:'.length) : id 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 { function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
const kind = shapeTypeToKind(shape.type) const kind = shapeTypeToKind(shape.type)
if (!kind) return null if (!kind) return null
const s = shape as ExamCanvasTLShape const s = shape as ExamCanvasTLShape
return { return {
id: s.props.domainId || stripShapePrefix(s.id), id: domainIdForShape(s),
kind, kind,
x: Number(s.x ?? 0), x: Number(s.x ?? 0),
y: Number(s.y ?? 0), y: Number(s.y ?? 0),
@@ -71,23 +97,38 @@ function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
type: SHAPE_TYPES[m.kind], type: SHAPE_TYPES[m.kind],
x: m.x, x: m.x,
y: m.y, y: m.y,
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 }, 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 },
}))) })))
} }
function newDomainId() { function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID() const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id)
return `manual-${Date.now()}-${Math.random().toString(16).slice(2)}` 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 seedGuide(editor: Editor) { function seedGuide(editor: Editor) {
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)) const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
if (current.length) return if (current.length) return
editor.createShapes([ editor.createShapes([
{ 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: 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', domainId: newDomainId(), label: 'Q1 end' } }, { 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', domainId: newDomainId(), label: 'Q1(a)', maxMarks: 3 } }, { 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', domainId: newDomainId(), label: 'Response', responseForm: 'lines' } }, { id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } },
]) ])
} }
@@ -96,39 +137,16 @@ const ExamTemplateSetupInner: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const theme = useTheme() const theme = useTheme()
const editorRef = useRef<Editor | null>(null) const editorRef = useRef<Editor | null>(null)
const pageLayoutsRef = useRef<PdfPageLayout[]>([]) const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
const hydratingRef = useRef(false)
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null) const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [pdfLoading, setPdfLoading] = useState(false)
const [pdfStatus, setPdfStatus] = useState<string>('PDF not loaded')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false) const [dirty, setDirty] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [conflict, setConflict] = useState<string | null>(null) const [conflict, setConflict] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState('select') const [activeTool, setActiveTool] = useState('select')
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
const loadPdf = useCallback(async (id: string, editor: Editor) => { const [pdfError, setPdfError] = useState<string | null>(null)
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 () => { const load = useCallback(async () => {
if (!templateId) return if (!templateId) return
@@ -136,12 +154,25 @@ const ExamTemplateSetupInner: React.FC = () => {
try { try {
const detail = await examRepository.getTemplate(templateId) const detail = await examRepository.getTemplate(templateId)
setTemplate(detail) 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 const editor = editorRef.current
if (editor) { if (editor) {
hydratingRef.current = true syncPdfPages(editor, pages)
loadShapes(editor, shapesFromTemplate(detail)) loadShapes(editor, shapesFromTemplate(detail, geometries))
hydratingRef.current = false
await loadPdf(templateId, editor)
} }
setDirty(false) setDirty(false)
} catch (e) { } catch (e) {
@@ -151,7 +182,7 @@ const ExamTemplateSetupInner: React.FC = () => {
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [loadPdf, templateId]) }, [templateId])
useEffect(() => { void load() }, [load]) useEffect(() => { void load() }, [load])
@@ -160,13 +191,12 @@ const ExamTemplateSetupInner: React.FC = () => {
if (!editor || !templateId || !template) return if (!editor || !templateId || !template) return
setSaving(true); setError(null); setConflict(null) setSaving(true); setError(null); setConflict(null)
try { try {
const shapes = editor.getCurrentPageShapes().filter((s) => s.type !== PDF_PAGE_SHAPE_TYPE).map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[] ensureDomainIds(editor)
const payload = serializeCanvasShapes(template, shapes, pageLayoutsRef.current) const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
const saved = await examRepository.replaceTemplate(templateId, payload) const saved = await examRepository.replaceTemplate(templateId, payload)
setTemplate(saved) setTemplate(saved)
hydratingRef.current = true loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
loadShapes(editor, shapesFromTemplate(saved))
hydratingRef.current = false
setDirty(false) setDirty(false)
} catch (e) { } catch (e) {
const msg = apiMessage(e) const msg = apiMessage(e)
@@ -202,24 +232,15 @@ const ExamTemplateSetupInner: React.FC = () => {
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas"> <Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
<Tldraw <Tldraw
shapeUtils={examCanvasShapeUtils as any} shapeUtils={examCanvasShapeUtils as any}
bindingUtils={defaultBindingUtils}
tools={examCanvasTools as any} tools={examCanvasTools as any}
hideUi hideUi
inferDarkMode={theme.palette.mode === 'dark'} inferDarkMode={theme.palette.mode === 'dark'}
autoFocus autoFocus
onMount={(editor) => { onMount={(editor) => {
editorRef.current = editor editorRef.current = editor
editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' }) editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
editor.store.listen((entry) => { editor.store.listen(() => setDirty(true), { scope: 'document' })
const changedPdfOnly = Object.values(entry.changes.added).every((record: any) => record?.type === PDF_PAGE_SHAPE_TYPE) if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
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> </Box>
@@ -229,9 +250,8 @@ const ExamTemplateSetupInner: React.FC = () => {
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography> <Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
<Typography variant="caption" color="text.secondary">Exam Marker Setup · real PDF backdrop is locked; drawn regions persist with PDF-derived page numbers.</Typography> <Typography variant="caption" color="text.secondary">Exam Marker Setup · draw boundaries, part boxes, and regions; Save persists a full replace.</Typography>
</Box> </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'} /> <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> <Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
</Paper> </Paper>
@@ -243,7 +263,10 @@ const ExamTemplateSetupInner: React.FC = () => {
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 420, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}> <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="subtitle2" gutterBottom>Setup guide</Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
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. 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'}
</Typography> </Typography>
</Paper> </Paper>
+34 -40
View File
@@ -1,3 +1,4 @@
import React from 'react' import React from 'react'
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw' import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model' import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
@@ -15,24 +16,25 @@ export const SHAPE_TYPES = {
furniture: 'exam-region-furniture', furniture: 'exam-region-furniture',
} as const } as const
export type PdfPageTLShape = TLBaseBoxShape<typeof PDF_PAGE_SHAPE_TYPE, { export type ExamPdfPageTLShape = TLBaseBoxShape & {
w: number type: typeof PDF_PAGE_SHAPE_TYPE
h: number props: { w: number; h: number; src: string; pageNumber: number }
src: string }
pageNumber: number
}>
export type ExamCanvasTLShape = TLBaseBoxShape<typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES], { export type ExamCanvasTLShape = TLBaseBoxShape & {
w: number type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
h: number props: {
label: string w: number
kind: ExamCanvasShapeKind h: number
domainId: string label: string
maxMarks?: number kind: ExamCanvasShapeKind
responseForm?: string maxMarks?: number
contextType?: string responseForm?: string
questionId?: string | null contextType?: string
}> questionId?: string | null
domainId?: string
}
}
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: 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' }, boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' },
@@ -45,13 +47,6 @@ const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' }, 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) { function renderShape(shape: ExamCanvasTLShape) {
const kind = shape.props.kind const kind = shape.props.kind
const p = palette[kind] ?? palette.response const p = palette[kind] ?? palette.response
@@ -76,34 +71,29 @@ function renderShape(shape: ExamCanvasTLShape) {
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
const p = palette[kind] const p = palette[kind]
return { w, h, label: p.label, kind, domainId: makeDomainId(), responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined } return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
} }
class PdfPageUtil extends BaseBoxShapeUtil<PdfPageTLShape> { 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> {
static override type = PDF_PAGE_SHAPE_TYPE static override type = PDF_PAGE_SHAPE_TYPE
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number } 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 getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
override canEdit() { return false } override canEdit() { return false }
override canResize() { return false } override component(shape: ExamPdfPageTLShape) {
override hideResizeHandles() { return true }
override hideRotateHandle() { return true }
override component(shape: PdfPageTLShape) {
return ( return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}> <HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
<img <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' }} />
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> </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 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 = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; 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 = 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 = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; 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 = 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 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 } class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
@@ -117,6 +107,10 @@ 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 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 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 { export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type) const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
+27 -63
View File
@@ -1,75 +1,39 @@
import type { Editor } from '@tldraw/tldraw' import * as pdfjsLib from "pdfjs-dist"
import * as pdfjsLib from 'pdfjs-dist' import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url"
import pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs?url'
import { examRepository } from '../../../services/exam/examRepository' import { PAGE_WIDTH } from "../../../utils/exam-canvas/model"
import { PAGE_GAP, PAGE_WIDTH, type PdfPageLayout } from '../../../utils/exam-canvas/model'
import { PDF_PAGE_SHAPE_TYPE, type PdfPageTLShape } from './examCanvasShapes'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc
export interface PdfBackdropPage { export interface PdfPageImage {
page: number pageNumber: number
src: string src: string
layout: PdfPageLayout width: number
height: number
} }
export interface PdfBackdropResult { export async function loadPdfPageImages(pdfBytes: ArrayBuffer, targetWidth = PAGE_WIDTH): Promise<PdfPageImage[]> {
pages: PdfBackdropPage[] const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise
pageLayouts: PdfPageLayout[] const pages: PdfPageImage[] = []
}
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) { for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
const page = await pdf.getPage(pageNumber) const page = await pdf.getPage(pageNumber)
const nativeViewport = page.getViewport({ scale: 1 }) const baseViewport = page.getViewport({ scale: 1 })
const layoutScale = PAGE_WIDTH / nativeViewport.width const scale = targetWidth / baseViewport.width
const renderScale = layoutScale * Math.min(window.devicePixelRatio || 1, 2) const viewport = page.getViewport({ scale })
const renderViewport = page.getViewport({ scale: renderScale }) const canvas = document.createElement("canvas")
const canvas = document.createElement('canvas') canvas.width = Math.ceil(viewport.width)
const context = canvas.getContext('2d') canvas.height = Math.ceil(viewport.height)
if (!context) throw new Error('Could not create 2D canvas for PDF rendering') const context = canvas.getContext("2d")
canvas.width = Math.ceil(renderViewport.width) if (!context) throw new Error("Unable to create PDF render canvas")
canvas.height = Math.ceil(renderViewport.height) await page.render({ canvasContext: context, viewport }).promise
await page.render({ canvasContext: context, viewport: renderViewport }).promise pages.push({
pageNumber,
const layout: PdfPageLayout = { src: canvas.toDataURL("image/png"),
page: pageNumber, width: canvas.width,
x: 0, height: canvas.height,
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
} }
await pdf.destroy() return pages
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,
},
})))
} }
+127 -7
View File
@@ -8,12 +8,17 @@
import axios from 'axios'; import axios from 'axios';
import { API_BASE } from '../../config/apiConfig'; import { API_BASE } from '../../config/apiConfig';
import { logger } from '../../debugConfig';
import { supabase } from '../../supabaseClient'; import { supabase } from '../../supabaseClient';
import type { import type {
CreateTemplatePayload, CreateTemplatePayload,
ExamBoundary,
ExamQuestion,
ExamResponseArea,
ExamTemplate, ExamTemplate,
ExamTemplateDetail, ExamTemplateDetail,
TemplateReplacePayload, TemplateReplacePayload,
UpdateTemplateMetaPayload,
} from '../../types/exam.types'; } from '../../types/exam.types';
const EXAM_BASE = `${API_BASE}/api/exam`; const EXAM_BASE = `${API_BASE}/api/exam`;
@@ -26,6 +31,86 @@ async function authHeaders(): Promise<Record<string, string>> {
return { Authorization: `Bearer ${session.access_token}` }; 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 = { export const examRepository = {
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> { async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
const headers = await authHeaders(); const headers = await authHeaders();
@@ -42,24 +127,59 @@ export const examRepository = {
return res.data; 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> { async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
const headers = await authHeaders(); const headers = await authHeaders();
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers }); const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
return res.data; 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> { async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
const headers = await authHeaders(); const headers = await authHeaders();
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers }); const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
return res.data; 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> { async archiveTemplate(templateId: string): Promise<void> {
const headers = await authHeaders(); const headers = await authHeaders();
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers }); await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
+16 -1
View File
@@ -31,6 +31,13 @@ export interface CreateTemplatePayload {
institute_id?: string; 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). */ /** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
export interface ExamQuestion { export interface ExamQuestion {
id: string; id: string;
@@ -48,13 +55,21 @@ export interface ExamQuestion {
page?: number | null; page?: number | null;
} }
export type ExamResponseAreaKind =
| 'response'
| 'context'
| 'question_number'
| 'mark_area'
| 'reference'
| 'furniture';
export interface ExamResponseArea { export interface ExamResponseArea {
id: string; id: string;
question_id: string; question_id: string;
template_id: string; template_id: string;
page: number; page: number;
bounds: Record<string, number>; bounds: Record<string, number>;
kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'; kind: ExamResponseAreaKind;
response_form: string | null; response_form: string | null;
context_type?: string | null; context_type?: string | null;
source: 'manual' | 'ai'; source: 'manual' | 'ai';
+28 -35
View File
@@ -1,72 +1,65 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { ExamTemplateDetail } from '../../types/exam.types' import type { ExamTemplateDetail } from '../../types/exam.types'
import { serializeCanvasShapes, shapesFromTemplate, type PdfPageLayout } from './model' import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model'
const template: ExamTemplateDetail = { const template: ExamTemplateDetail = {
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1, 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: [], 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', () => { describe('exam setup canvas serialization', () => {
it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => { it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => {
const payload = serializeCanvasShapes(template, [ const payload = serializeCanvasShapes(template, [
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' }, { id: 'b-top', kind: 'boundary', 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: 'b-bottom', kind: 'boundary', 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: 'part-1', 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' }, { id: 'resp-1', kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' },
]) ])
const main = payload.questions.find((q) => q.is_container) const main = payload.questions.find((q) => q.is_container)
const part = payload.questions.find((q) => q.id === ids.part) const part = payload.questions.find((q) => !q.is_container)
expect(main?.label).toBe('Q1') expect(main?.label).toBe('Q1')
expect(part?.parent_id).toBe(main?.id) expect(part?.parent_id).toBe(main?.id)
expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 }) expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 })
expect(payload.response_areas[0]).toMatchObject({ id: ids.response, question_id: ids.part, kind: 'response', response_form: 'lines' }) expect(payload.response_areas[0]).toMatchObject({ question_id: part?.id, kind: 'response', response_form: 'lines' })
expect(payload.boundaries).toHaveLength(2) expect(payload.boundaries).toHaveLength(2)
expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true) 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('uses real PDF page layouts to save visual page numbers', () => { it('maps shapes to the visible PDF page geometry rather than a fixed page height', () => {
const layouts: PdfPageLayout[] = [ const pages = [
{ page: 1, x: 0, y: 0, w: 780, h: 1000 }, { pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 },
{ page: 2, x: 0, y: 1040, w: 780, h: 980 }, { pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 },
] ]
expect(pageForY(1050, pages)).toBe(2)
const payload = serializeCanvasShapes(template, [ const payload = serializeCanvasShapes(template, [
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 1060, w: 700, h: 8, label: 'Q2 start' }, { id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' },
{ id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 1800, w: 700, h: 8, label: 'Q2 end' }, { id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' },
{ id: ids.part, kind: 'part', x: 100, y: 1120, w: 400, h: 220, label: 'Q2(a)', maxMarks: 3 }, { id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' },
{ id: ids.response, kind: 'response', x: 130, y: 1250, w: 300, h: 90, responseForm: 'lines' }, { id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 },
], layouts) ], pages)
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
expect(payload.boundaries.map((b) => b.page_index)).toEqual([1, 1]) expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
expect(payload.questions.find((q) => q.id === ids.part)?.page).toBe(2)
expect(payload.response_areas[0].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', () => { it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
const shapes = shapesFromTemplate({ const shapes = shapesFromTemplate({
...template, ...template,
questions: [ questions: [
{ 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: '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: 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 }, { 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 },
], ],
response_areas: [ response_areas: [
{ 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: '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: '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 }, { 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 },
], ],
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 }], 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 }],
}) })
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response']) expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: ids.part, x: 1, y: 2, w: 3, h: 4 }) expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
}) })
}) })
+51 -50
View File
@@ -1,18 +1,19 @@
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types' import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
export const PAGE_HEIGHT = 1100
export const PAGE_WIDTH = 780 export const PAGE_WIDTH = 780
export const DEFAULT_PAGE_HEIGHT = 1100 export const PAGE_GAP = 0
export const PAGE_GAP = 40
// Back-compat alias for S4-9a callers/tests. Real PDF pages use PdfPageLayout heights. export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number }
export const PAGE_HEIGHT = DEFAULT_PAGE_HEIGHT + PAGE_GAP
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture' export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
export interface CanvasBounds { x: number; y: number; w: number; h: number } 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 { export interface ExamCanvasShapeModel {
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
id: string id: string
kind: ExamCanvasShapeKind kind: ExamCanvasShapeKind
x: number x: number
@@ -27,29 +28,41 @@ export interface ExamCanvasShapeModel {
questionId?: string | null questionId?: string | null
} }
export function fallbackPageLayouts(pageCount = 4): PdfPageLayout[] { export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
return Array.from({ length: Math.max(pageCount, 1) }, (_, i) => ({ if (pages?.length) {
page: i + 1, const hit = pages.find((page) => y >= page.y && y <= page.y + page.h)
x: 0, if (hit) return hit.pageNumber
y: i * PAGE_HEIGHT, const nearest = pages.reduce((best, page) => {
w: PAGE_WIDTH, const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h)))
h: DEFAULT_PAGE_HEIGHT, 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 pageForY(y: number, layouts?: PdfPageLayout[]): number { export function pageTop(page: number, pages?: CanvasPageGeometry[]): number {
if (!layouts?.length) return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1) const hit = pages?.find((p) => p.pageNumber === page)
const hit = layouts.find((layout) => y >= layout.y && y <= layout.y + layout.h) return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP))
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
} }
export function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, layouts?: PdfPageLayout[]): number { function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): number {
return pageForY(shape.y + shape.h / 2, layouts) 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)
})
} }
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds { function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
@@ -71,21 +84,10 @@ function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, s
return cy >= minY && cy <= maxY return cy >= minY && cy <= maxY
} }
function uuid(): string { export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload {
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 const orderedBoundaries = shapes
.filter((s) => s.kind === 'boundary') .filter((s) => s.kind === 'boundary')
.sort((a, b) => (pageForShape(a, pageLayouts) - pageForShape(b, pageLayouts)) || (a.y - b.y)) .sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y))
const parts = shapes.filter((s) => s.kind === 'part') const parts = shapes.filter((s) => s.kind === 'part')
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part') const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
@@ -98,52 +100,51 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
const bottom = orderedBoundaries[i + 1] const bottom = orderedBoundaries[i + 1]
if (!top || !bottom) break if (!top || !bottom) break
const qNum = bands.length + 1 const qNum = bands.length + 1
const questionId = stableUuid(top.questionId || bottom.questionId) const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}` 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: {} }) questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
bands.push({ questionId, top, bottom }) bands.push({ questionId, top, bottom })
for (const b of [top, bottom]) { for (const b of [top, bottom]) {
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 }) 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 })
} }
} }
const partQuestionIds = new Map<string, string>() const partQuestionIds = new Map<string, string>()
parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => { 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 parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
const qid = stableUuid(part.questionId || part.id) const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
partQuestionIds.set(part.id, qid) 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, pageLayouts) }) 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) })
}) })
const response_areas: TemplateReplacePayload['response_areas'] = [] const response_areas: TemplateReplacePayload['response_areas'] = []
for (const region of regions) { for (const region of regions) {
const containingPart = parts.find((part) => contains(bounds(part), bounds(region))) const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
const regionPage = pageForShape(region, pageLayouts) const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
const fallbackPart = parts.find((part) => pageForShape(part, pageLayouts) === regionPage) ?? parts[0]
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
if (!questionId) continue if (!questionId) continue
const kind = region.kind as ExamCanvasRegionKind const kind = region.kind as ExamCanvasRegionKind
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 }) 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 })
} }
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: pageLayouts?.length ?? template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries } return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
} }
export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeModel[] { export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
const shapes: ExamCanvasShapeModel[] = [] const shapes: ExamCanvasShapeModel[] = []
const questions = new Map(detail.questions.map((q) => [q.id, q])) const questions = new Map(detail.questions.map((q) => [q.id, q]))
for (const b of detail.boundaries ?? []) { for (const b of detail.boundaries ?? []) {
const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 } const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
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 }) 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 })
} }
for (const q of detail.questions ?? []) { for (const q of detail.questions ?? []) {
if (q.is_container || !q.bounds) continue if (q.is_container || !q.bounds) continue
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 }) 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 })
} }
for (const r of detail.response_areas ?? []) { for (const r of detail.response_areas ?? []) {
const bb = r.bounds ?? { x: 100, y: (r.page - 1) * PAGE_HEIGHT + 360, w: 360, h: 120 } const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
const q = questions.get(r.question_id) const q = questions.get(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 }) 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 })
} }
return shapes return shapes
} }