diff --git a/Dockerfile b/Dockerfile index 0a5c0ba..7f87db7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,9 @@ FROM nginx:alpine # Copy built files COPY --from=builder /app/dist /usr/share/nginx/html +# .mjs files (pdfjs worker) must be served as application/javascript for module workers +RUN sed -i 's|application/javascript\s*js;|application/javascript js mjs;|' /etc/nginx/mime.types + # Create a simple nginx configuration RUN echo 'server { \ listen 3000; \ diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx index 501060e..59616e3 100644 --- a/src/AppRoutes.admin.test.tsx +++ b/src/AppRoutes.admin.test.tsx @@ -33,6 +33,7 @@ vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () =>
Single P vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () =>
Multiplayer
})); vi.mock('./pages/exam', () => ({ ExamDashboardPage: () =>
Exam Marker
, + ExamTemplateSetupPage: () =>
Exam Template Setup
, MarkSchemePage: () =>
Mark Scheme editor
, })); vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
})); diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index 4c3330c..681a452 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage'; import SignupPage from './pages/auth/signupPage'; import SinglePlayerPage from './pages/tldraw/singlePlayerPage'; import MultiplayerUser from './pages/tldraw/multiplayerUser'; -import { ExamDashboardPage, MarkSchemePage } from './pages/exam'; +import { ExamDashboardPage, ExamTemplateSetupPage, MarkSchemePage } from './pages/exam'; import { ErrorBoundary } from './components/ErrorBoundary'; import CalendarPage from './pages/user/calendarPage'; import SettingsPage from './pages/user/settingsPage'; @@ -183,6 +183,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx index 5080792..7e3a7c7 100644 --- a/src/pages/exam/ExamDashboardPage.tsx +++ b/src/pages/exam/ExamDashboardPage.tsx @@ -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 { Alert, @@ -22,6 +22,8 @@ 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 GradingIcon from '@mui/icons-material/Grading'; import { useAuth } from '../../contexts/AuthContext'; @@ -35,6 +37,51 @@ const STATUS_COLOR: Record = 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(); @@ -44,8 +91,9 @@ const ExamDashboardPage: React.FC = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [createOpen, setCreateOpen] = useState(false); - const [title, setTitle] = useState(''); + const [dialog, setDialog] = useState(null); + const [templateName, setTemplateName] = useState(''); + const [version, setVersion] = useState('v1'); const [subject, setSubject] = useState(''); const [saving, setSaving] = useState(false); @@ -67,33 +115,99 @@ const ExamDashboardPage: React.FC = () => { void load(); }, [load, instituteId]); - const handleCreate = async () => { - if (!title.trim()) return; + const groupedTemplates = useMemo(() => { + const groups = new Map(); + 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); try { - const created = await examRepository.createTemplate({ - title: title.trim(), - subject: subject.trim() || undefined, - institute_id: instituteId, + if (dialog.mode === 'create') { + const created = await examRepository.createTemplate({ + title, + 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); - setTitle(''); - setSubject(''); - navigate(`/exam-marker/${created.id}/setup`); + 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', 'Create template failed', { message: msg }); + logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode }); setError(msg); } finally { setSaving(false); } }; - const handleArchive = async (id: string, ev: React.MouseEvent) => { + const handleArchive = async (template: ExamTemplate, 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(id); - setTemplates((prev) => prev.filter((t) => t.id !== id)); + await examRepository.archiveTemplate(template.id); + setTemplates((prev) => prev.filter((t) => t.id !== template.id)); } catch (e) { const msg = e instanceof Error ? e.message : String(e); logger.error('cc-exam-marker', 'Archive failed', { message: msg }); @@ -101,6 +215,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 ( @@ -109,11 +229,11 @@ const ExamDashboardPage: React.FC = () => { Exam Marker - - 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. - @@ -133,83 +253,128 @@ const ExamDashboardPage: React.FC = () => { No exam templates yet - Create your first template to start mapping an exam paper. + Create your first named template to start mapping an exam paper. - ) : ( - - {templates.map((t) => ( - - navigate(`/exam-marker/${t.id}/setup`)} - > - - {t.title} - - handleArchive(t.id, e)} aria-label="archive template"> - - - - - {t.subject && ( - {t.subject} - )} - {t.exam_code && ( - {t.exam_code} - )} - - - - {new Date(t.updated_at).toLocaleDateString()} - - - - - - - + + {groupedTemplates.map((group) => ( + + + {group.label} + + + + {group.templates.map((t) => { + const parsed = splitTemplateTitle(t.title); + return ( + + navigate(`/exam-marker/${t.id}/setup`)} + > + + + {parsed.name} + + + + + openEdit(t, e)} aria-label="rename template"> + + + + + openDuplicate(t, e)} aria-label="duplicate template"> + + + + + handleArchive(t, e)} aria-label="archive template"> + + + + + + {t.subject && ( + {t.subject} + )} + {t.exam_code && ( + {t.exam_code} + )} + + + + + Updated {new Date(t.updated_at).toLocaleDateString()} + + + + + + + ); + })} + + ))} - + )} - (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm"> - New exam template + + {dialogTitle} setTitle(e.target.value)} + label="Template name" + value={templateName} + onChange={(e) => setTemplateName(e.target.value)} fullWidth autoFocus required + helperText="User-facing name. Several templates can share the same paper." /> setVersion(e.target.value)} + fullWidth + helperText="Stored in the template title until the API grows a dedicated version column." + /> + setSubject(e.target.value)} fullWidth + disabled={dialog?.mode === 'duplicate'} /> - - + diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts index 7f5a856..ace4853 100644 --- a/src/pages/exam/index.ts +++ b/src/pages/exam/index.ts @@ -1,2 +1,3 @@ -export { default as ExamDashboardPage } from './ExamDashboardPage'; -export { default as MarkSchemePage } from './MarkSchemePage'; +export { default as ExamDashboardPage } from ./ExamDashboardPage; +export { default as ExamTemplateSetupPage } from ./setup/ExamTemplateSetupPage; +export { default as MarkSchemePage } from ./MarkSchemePage; diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx new file mode 100644 index 0000000..7f020b5 --- /dev/null +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -0,0 +1,311 @@ + +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import SaveIcon from '@mui/icons-material/Save' +import MouseIcon from '@mui/icons-material/Mouse' +import '@tldraw/tldraw/tldraw.css' +import { Editor, Tldraw, createShapeId, 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, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes' +import { loadPdfPageImages, PdfPageImage } from './pdfLoader' + +const TOOLS = [ + { id: 'select', label: 'Select', icon: '↖', tip: 'Move, resize, delete, or inspect the Attached pill on a region.', color: 'inherit' as const }, + { id: SHAPE_TYPES.boundary, label: 'Boundary', icon: canvasShapePalette.boundary.icon, tip: 'Draw Q start and Q end horizontal rules; an end rule on a later page creates a multi-page question span.', color: 'error' as const }, + { id: SHAPE_TYPES.part, label: 'Part', icon: canvasShapePalette.part.icon, tip: 'Draw the markable sub-question box inside a boundary pair; it becomes the leaf question/part.', color: 'warning' as const }, + { id: SHAPE_TYPES.response, label: 'Response', icon: canvasShapePalette.response.icon, tip: 'Draw around where the student writes; blue regions save as response areas.', color: 'primary' as const }, + { id: SHAPE_TYPES.context, label: 'Context', icon: canvasShapePalette.context.icon, tip: 'Draw stimulus, figures, tables, or prompt text; purple dashed regions save as context.', color: 'secondary' as const }, + { id: SHAPE_TYPES.question_number, label: 'Q Number', icon: canvasShapePalette.question_number.icon, tip: 'Box the printed question number for OCR/structure extraction.', color: 'success' as const }, + { id: SHAPE_TYPES.mark_area, label: 'Mark Area', icon: canvasShapePalette.mark_area.icon, tip: 'Box printed marks such as [2] or Total for Question text.', color: 'success' as const }, + { id: SHAPE_TYPES.reference, label: 'Reference', icon: canvasShapePalette.reference.icon, tip: 'Box formulae, data sheets, appendices, or other resources the student may use.', color: 'info' as const }, + { id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', 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 } { + if (axios.isAxiosError(err)) { + const detail = (err.response?.data as { detail?: string } | undefined)?.detail + if (err.response?.status === 409) return { conflict: true, message: detail ?? 'Template has recorded marks; structural full-replace is blocked.' } + return { conflict: false, message: detail ?? err.message } + } + return { conflict: false, message: err instanceof Error ? err.message : String(err) } +} + +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), + kind, + x: Number(s.x ?? 0), + y: Number(s.y ?? 0), + w: Number(s.props.w ?? 1), + h: Number(s.props.h ?? 1), + label: s.props.label, + maxMarks: s.props.maxMarks, + responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'], + contextType: s.props.contextType, + questionId: s.props.questionId ?? null, + } +} + +function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) { + const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id) + if (existing.length) editor.deleteShapes(existing) + if (!models.length) return + editor.createShapes(models.map((m) => ({ + id: createShapeId(m.id), + 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 }, + }))) +} + +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 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: PAGE_HEIGHT + 160, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end (page 2)', 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.context, x: 116, y: 495, props: { w: 470, h: 90, kind: 'context', label: 'Context', contextType: 'generic', domainId: newDomainId() } }, + ]) +} + +const ExamTemplateSetupInner: React.FC = () => { + const { templateId } = useParams<{ templateId: string }>() + const navigate = useNavigate() + const theme = useTheme() + const editorRef = useRef(null) + const pageGeometriesRef = useRef([]) + const [template, setTemplate] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [dirty, setDirty] = useState(false) + const [error, setError] = useState(null) + const [conflict, setConflict] = useState(null) + const [activeTool, setActiveTool] = useState('select') + const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading') + const [pdfError, setPdfError] = useState(null) + + const load = useCallback(async () => { + if (!templateId) return + setLoading(true); setError(null); setConflict(null) + 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, undefined, (partialPages) => { + const newPage = partialPages[partialPages.length - 1] + const allGeometries = pageGeometryFromImages(partialPages) + pageGeometriesRef.current = allGeometries + const ed = editorRef.current + if (ed) { + const geometry = allGeometries[partialPages.length - 1] + const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber) + if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) { + ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any]) + try { ed.sendToBack([shapeId as any]) } catch { /* */ } + } + } + setPdfStatus('ready') + }) + 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)) + } + setDirty(false) + } catch (e) { + const msg = apiMessage(e).message + logger.warn('cc-exam-marker', 'Template setup load failed', { templateId, message: msg }) + setError(msg) + } finally { + setLoading(false) + } + }, [templateId]) + + useEffect(() => { void load() }, [load]) + + const save = useCallback(async () => { + const editor = editorRef.current + 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 saved = await examRepository.replaceTemplate(templateId, payload) + setTemplate(saved) + loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current)) + setDirty(false) + } catch (e) { + const msg = apiMessage(e) + if (msg.conflict) setConflict(msg.message); else setError(msg.message) + logger.warn('cc-exam-marker', 'Template setup save failed', { templateId, message: msg.message }) + } finally { + setSaving(false) + } + }, [template, templateId]) + + const toolButtons = useMemo(() => TOOLS.map((tool) => ( + + + + )), [activeTool]) + + return ( + t.zIndex.drawer + 20, bgcolor: 'background.default' }}> + + { + 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) + }} + /> + + + + + + + {template?.title ?? 'Template setup'} + Exam Marker › Setup · coloured tools map to persisted regions; boundary start/end pairs can span pages. + + + + + + + {toolButtons} + + + + Setup guide + + 1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment. + + + {(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => { + const p = canvasShapePalette[kind] + return + })} + + + Multi-page boundary pairing + Draw “Q start” on page N, then “Q end” on a later page; save pairs boundaries by reading order into one question span. + Open design choices resolved for v1: labels use “Q start/end”; persistent Attached pills confirm containment; rectangles stay simple for dense multi-column papers; Back button remains explicit. + + PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'} + + + + {loading && } + {conflict && setConflict(null)}>{conflict}} + setError(null)}> setError(null)}>{error} + + ) +} + +const ExamTemplateSetupPage: React.FC = () => ( + Template setup canvas crashed. Reload the page and try again.}> + + +) + +export default ExamTemplateSetupPage diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx new file mode 100644 index 0000000..a58b748 --- /dev/null +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -0,0 +1,147 @@ + +import React from 'react' +import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw' +import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model' + +export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page' + +export const SHAPE_TYPES = { + boundary: 'exam-boundary', + part: 'exam-part', + response: 'exam-region-response', + context: 'exam-region-context', + question_number: 'exam-region-question-number', + mark_area: 'exam-region-mark-area', + reference: 'exam-region-reference', + 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 ExamCanvasTLShape = TLBaseBoxShape & { + type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES] + props: { + w: number + h: number + label: string + kind: ExamCanvasShapeKind + maxMarks?: number + responseForm?: string + contextType?: string + questionId?: string | null + domainId?: string + } +} + +type CanvasPaletteEntry = { + stroke: string + fill: string + darkStroke: string + darkFill: string + dash?: string + label: string + icon: string + role: string +} + +export const canvasShapePalette: Record = { + boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', darkStroke: '#f87171', darkFill: 'rgba(248,113,113,0.10)', dash: '8 6', label: 'Boundary', icon: '↕', role: 'start/end rule' }, + part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.18)', darkStroke: '#fbbf24', darkFill: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', role: 'markable box' }, + response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.18)', darkStroke: '#60a5fa', darkFill: 'rgba(96,165,250,0.34)', label: 'Response', icon: '✎', role: 'student writing' }, + context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', darkStroke: '#a78bfa', darkFill: 'rgba(167,139,250,0.28)', dash: '6 5', label: 'Context', icon: '◉', role: 'stimulus' }, + question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', darkStroke: '#2dd4bf', darkFill: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', role: 'printed label' }, + mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', darkStroke: '#4ade80', darkFill: 'rgba(74,222,128,0.23)', label: 'Marks', icon: '[2]', role: 'printed marks' }, + reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', darkStroke: '#22d3ee', darkFill: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '§', role: 'resource' }, + furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' }, +} + +const shapeCss = ` +.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); } +[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); } +.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); } +[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); } +` + +function renderShape(shape: ExamCanvasTLShape) { + const kind = shape.props.kind + const p = canvasShapePalette[kind] ?? canvasShapePalette.response + const isBoundary = kind === 'boundary' + return ( + + +
+ + + {shape.props.label || p.label} + + {!isBoundary && shape.props.questionId && Attached} + {isBoundary && pair across pages} +
+
+ ) +} + +function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { + const p = canvasShapePalette[kind] + return { w, h, label: p.label, kind, 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) => + +class PdfPageUtil extends BaseBoxShapeUtil { + 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) { + return ( + + {'PDF + + ) + } + override indicator(shape: ExamPdfPageTLShape) { return ind(shape) } +} +class BoundaryUtil extends BaseBoxShapeUtil { 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 { 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 { 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 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 ResponseTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.response; static override initial = 'pointing'; shapeType = SHAPE_TYPES.response } +class ContextTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.context; static override initial = 'pointing'; shapeType = SHAPE_TYPES.context } +class QuestionNumberTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.question_number; static override initial = 'pointing'; shapeType = SHAPE_TYPES.question_number } +class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.mark_area; static override initial = 'pointing'; shapeType = SHAPE_TYPES.mark_area } +class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference } +class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture } + +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 +} diff --git a/src/pages/exam/setup/pdfLoader.ts b/src/pages/exam/setup/pdfLoader.ts new file mode 100644 index 0000000..4e3bfae --- /dev/null +++ b/src/pages/exam/setup/pdfLoader.ts @@ -0,0 +1,47 @@ +import * as pdfjsLib from "pdfjs-dist" +import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url" + +import { PAGE_WIDTH } from "../../../utils/exam-canvas/model" + +pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc + +export interface PdfPageImage { + pageNumber: number + src: string + width: number + height: number +} + +export async function loadPdfPageImages( + pdfBytes: ArrayBuffer, + targetWidth = PAGE_WIDTH, + onPageReady?: (pages: PdfPageImage[]) => void, +): Promise { + const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise + const pages: PdfPageImage[] = [] + // Reuse a single canvas across all pages to avoid allocating ~120 MB of canvas memory + // for a typical 36-page exam paper. + const canvas = document.createElement("canvas") + + 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 }) + 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") + context.clearRect(0, 0, canvas.width, canvas.height) + await page.render({ canvasContext: context, viewport }).promise + pages.push({ + pageNumber, + src: canvas.toDataURL("image/png"), + width: canvas.width, + height: canvas.height, + }) + onPageReady?.([...pages]) + } + + return pages +} diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index d9fd7e5..b12bd1a 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -8,14 +8,20 @@ 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, Neo4jSyncResult, PatchQuestionPayload, SpecPoint, + TemplateReplacePayload, + UpdateTemplateMetaPayload, } from '../../types/exam.types'; const EXAM_BASE = `${API_BASE}/api/exam`; @@ -28,6 +34,86 @@ async function authHeaders(): Promise> { 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) { + 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, 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, 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 { + const headers = await authHeaders(); + const idMap = new Map(); + if (duplicateIds) { + detail.questions.forEach((q) => idMap.set(q.id, newUuid())); + } + const res = await axios.put( + `${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 { const headers = await authHeaders(); @@ -44,12 +130,59 @@ export const examRepository = { return res.data; }, + async getTemplateSourcePdf(templateId: string): Promise { + const headers = await authHeaders(); + const res = await axios.get(`${EXAM_BASE}/templates/${templateId}/source-pdf`, { + headers, + responseType: 'arraybuffer', + }); + return res.data; + }, + async createTemplate(payload: CreateTemplatePayload): Promise { const headers = await authHeaders(); const res = await axios.post(`${EXAM_BASE}/templates`, payload, { headers }); return res.data; }, + async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise { + const headers = await authHeaders(); + const res = await axios.patch(`${EXAM_BASE}/templates/${templateId}`, meta, { headers }); + return res.data; + }, + + async duplicateTemplate(templateId: string, title: string): Promise { + 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 { + const headers = await authHeaders(); + const res = await axios.put(`${EXAM_BASE}/templates/${templateId}`, payload, { headers }); + return res.data; + }, async archiveTemplate(templateId: string): Promise { const headers = await authHeaders(); await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers }); diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index 6f12c2d..e0e6d17 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -4,7 +4,7 @@ * the shared UUIDs (template/question/region ids, exam_code). */ -export type ExamTemplateStatus = 'draft' | 'ready' | 'archived'; +export type ExamTemplateStatus = draft | ready | archived; export interface ExamTemplate { id: string; @@ -31,7 +31,7 @@ export interface CreateTemplatePayload { institute_id?: string; } -export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free'; +export type MarkSchemeType = points | levels | parts | checklist | free; export interface MarkSchemePoint { mark: number; @@ -67,6 +67,13 @@ export interface MarkScheme { [key: string]: unknown; } +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; @@ -84,16 +91,24 @@ 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; - kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'; + kind: ExamResponseAreaKind; response_form: string | null; context_type?: string | null; - source: 'manual' | 'ai'; + source: manual | ai; confirmed: boolean; confidence: number | null; } @@ -106,7 +121,7 @@ export interface ExamBoundary { page_index: number; y: number; bounds: Record | null; - source: 'manual' | 'ai'; + source: manual | ai; confirmed: boolean; } @@ -120,7 +135,7 @@ export interface PatchQuestionPayload { label?: string; order?: number; max_marks?: number; - answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null; + answer_type?: written | mcq | short | diagram | null; mcq_options?: unknown; mark_scheme?: MarkScheme; is_container?: boolean; @@ -140,3 +155,48 @@ export interface Neo4jSyncResult { status: string; projection?: Record; } + +export interface TemplateReplacePayload { + meta?: { + title?: string; + subject?: string; + page_count?: number; + status?: ExamTemplateStatus; + }; + questions: Array<{ + id?: string; + parent_id?: string | null; + label: string; + order?: number; + max_marks?: number; + answer_type?: written | mcq | short | diagram | null; + mcq_options?: unknown | null; + mark_scheme?: MarkScheme; + is_container?: boolean; + spec_ref?: string | null; + bounds?: Record | null; + page?: number | null; + }>; + response_areas: Array<{ + id?: string; + question_id: string; + page: number; + bounds: Record; + kind: ExamResponseAreaKind; + response_form?: string | null; + context_type?: string | null; + source?: manual | ai; + confirmed?: boolean; + confidence?: number | null; + }>; + boundaries: Array<{ + id?: string; + question_id?: string | null; + label?: string | null; + page_index: number; + y: number; + bounds?: Record | null; + source?: manual | ai; + confirmed?: boolean; + }>; +} diff --git a/src/utils/exam-canvas/model.test.ts b/src/utils/exam-canvas/model.test.ts new file mode 100644 index 0000000..52b40b7 --- /dev/null +++ b/src/utils/exam-canvas/model.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import type { ExamTemplateDetail } from '../../types/exam.types' +import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } 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: [], +} + +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' }, + ]) + + const main = payload.questions.find((q) => q.is_container) + const part = payload.questions.find((q) => !q.is_container) + 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.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 }, + ] + 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) + expect(payload.response_areas[0].page).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 }, + ], + 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 }, + ], + 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.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 }) + }) +}) diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts new file mode 100644 index 0000000..22bdb54 --- /dev/null +++ b/src/utils/exam-canvas/model.ts @@ -0,0 +1,150 @@ + +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 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 ExamCanvasShapeModel { + /** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */ + id: string + kind: ExamCanvasShapeKind + x: number + y: number + w: number + h: number + label?: string + maxMarks?: number + answerType?: 'written' | 'mcq' | 'short' | 'diagram' + responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks' + contextType?: string + 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 pageTop(page: number, pages?: CanvasPageGeometry[]): number { + const hit = pages?.find((p) => p.pageNumber === page) + return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP)) +} + +function pageForShape(shape: Pick, 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) + }) +} + +function bounds(shape: Pick): CanvasBounds { + return { x: shape.x, y: shape.y, w: shape.w, h: shape.h } +} + +function contains(outer: CanvasBounds, inner: CanvasBounds): boolean { + const ox2 = outer.x + outer.w + const oy2 = outer.y + outer.h + const ix2 = inner.x + inner.w + const iy2 = inner.y + inner.h + return inner.x >= outer.x && inner.y >= outer.y && ix2 <= ox2 && iy2 <= oy2 +} + +function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, shape: ExamCanvasShapeModel): boolean { + const minY = Math.min(top.y, bottom.y) + const maxY = Math.max(top.y, bottom.y) + const cy = shape.y + shape.h / 2 + return cy >= minY && cy <= maxY +} + +export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload { + const orderedBoundaries = shapes + .filter((s) => s.kind === 'boundary') + .sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y)) + const parts = shapes.filter((s) => s.kind === 'part') + const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part') + + const questions: TemplateReplacePayload['questions'] = [] + const boundaries: TemplateReplacePayload['boundaries'] = [] + const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = [] + + for (let i = 0; i < orderedBoundaries.length; i += 2) { + const top = orderedBoundaries[i] + 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 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 }) + } + } + + const partQuestionIds = new Map() + 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() + 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) }) + }) + + 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 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 }) + } + + 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, pages?: CanvasPageGeometry[]): 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 }) + } + 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 }) + } + for (const r of detail.response_areas ?? []) { + const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 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 }) + } + return shapes +}