import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { Alert, Box, Button, Chip, CircularProgress, Collapse, Divider, IconButton, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material' import ArrowBackIcon from '@mui/icons-material/ArrowBack' import HelpOutlineIcon from '@mui/icons-material/HelpOutline' import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh' 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 { AutoMapJobStatus, 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 = 0 const PDF_PAGE_IDS_PREFIX = 'pdf-page-' function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] { // S5 coordinate contract: use the actual pdf.js raster dimensions that feed each page src. // Server mapper emits canvas coordinates against the same PAGE_START_X=0 and stacked page heights. 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 applyDocViewConstraints(editor: Editor, pages: PdfPageImage[]) { const maxW = pages.length ? Math.max(...pages.map((p) => p.width)) : PAGE_WIDTH const totalH = pages.reduce((sum, p) => sum + p.height, 0) || PAGE_HEIGHT editor.setCameraOptions({ constraints: { bounds: { x: -64, y: -64, w: maxW + 128, h: totalH + 128 }, padding: { x: 64, y: 64 }, origin: { x: 0.5, y: 0 }, initialZoom: 'fit-x-100', baseZoom: 'default', behavior: 'contain', }, isLocked: false, }) } 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 reviewSummary(template: ExamTemplateDetail | null) { if (!template) return { ai: 0, unconfirmed: 0, lowConfidence: 0 } const rows = [...(template.questions ?? []), ...(template.response_areas ?? []), ...(template.boundaries ?? []), ...(template.layout ?? [])] return rows.reduce((acc, row) => { if (row.source === 'ai') acc.ai += 1 if (row.source === 'ai' && row.confirmed === false) acc.unconfirmed += 1 if (typeof row.confidence === 'number' && row.confidence < 0.7) acc.lowConfidence += 1 return acc }, { ai: 0, unconfirmed: 0, lowConfidence: 0 }) } 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, source: s.props.source ?? 'manual', confirmed: s.props.confirmed ?? s.props.source !== 'ai', confidence: typeof s.props.confidence === 'number' ? s.props.confidence : null, derivation: s.props.derivation ?? null, } } function bringDomainShapesToFront(editor: Editor) { const ids = editor.getCurrentPageShapes().filter((s) => !!shapeTypeToKind(s.type)).map((s) => s.id) if (ids.length) try { editor.bringToFront(ids as any) } catch { /* */ } } 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, source: m.source ?? 'manual', confirmed: m.confirmed ?? m.source !== 'ai', confidence: m.confidence ?? undefined, derivation: m.derivation ?? undefined, reviewFlags: m.reviewFlags?.join('|') }, }))) } 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 })) // z-order is enforced by the caller via bringDomainShapesToFront } 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() } }, ]) } function isAutoMapAccepted(value: unknown): value is { status: 'accepted'; job_id: string } { return !!value && typeof value === 'object' && (value as { status?: string }).status === 'accepted' && typeof (value as { job_id?: unknown }).job_id === 'string' } function autoMapStatusLabel(status: AutoMapJobStatus | null): string { if (!status) return 'Auto-map running' if (status.status === 'queued') return 'Auto-map queued' if (status.status === 'running') return 'Auto-map running' if (status.status === 'completed') return 'Auto-map complete' if (status.status === 'failed') return 'Auto-map failed' return `Auto-map ${status.status}` } 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 [guideOpen, setGuideOpen] = useState(false) const [autoMapStatus, setAutoMapStatus] = useState(null) const [autoMapBusy, setAutoMapBusy] = useState(false) const autoMapPollRef = useRef(null) const applyTemplateToCanvas = useCallback((detail: ExamTemplateDetail) => { setTemplate(detail) const editor = editorRef.current if (editor) { const shapes = shapesFromTemplate(detail, pageGeometriesRef.current) loadShapes(editor, shapes) if (!shapes.length) seedGuide(editor) bringDomainShapesToFront(editor) } setDirty(false) }, []) const review = useMemo(() => reviewSummary(template), [template]) 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]) bringDomainShapesToFront(ed) } applyDocViewConstraints(ed, partialPages) } 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)) bringDomainShapesToFront(editor) applyDocViewConstraints(editor, pages) editor.resetZoom() } 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]) useEffect(() => () => { if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current) }, []) const pollAutoMapStatus = useCallback(async (jobId: string) => { if (!templateId) return try { const status = await examRepository.getAutoMapStatus(templateId, jobId) setAutoMapStatus(status) if (status.status === 'completed') { if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current) autoMapPollRef.current = null setAutoMapBusy(false) const detail = status.template ?? await examRepository.getTemplate(templateId) applyTemplateToCanvas(detail) return } if (status.status === 'failed') { if (autoMapPollRef.current !== null) window.clearTimeout(autoMapPollRef.current) autoMapPollRef.current = null setAutoMapBusy(false) setError(status.error ?? 'Auto-map failed; existing template state was preserved.') return } autoMapPollRef.current = window.setTimeout(() => void pollAutoMapStatus(jobId), 2500) } catch (e) { const msg = apiMessage(e).message setAutoMapBusy(false) setError(msg) logger.warn('cc-exam-marker', 'Auto-map status poll failed', { templateId, jobId, message: msg }) } }, [applyTemplateToCanvas, templateId]) const autoMapFromPdf = useCallback(async () => { if (!templateId || autoMapBusy) return let queued = false setAutoMapBusy(true); setAutoMapStatus(null); setError(null); setConflict(null) try { const result = await examRepository.autoMapTemplate(templateId) if (isAutoMapAccepted(result)) { queued = true setAutoMapStatus({ job_id: result.job_id, status: 'queued', template_id: templateId }) await pollAutoMapStatus(result.job_id) return } setAutoMapStatus(null) applyTemplateToCanvas(result) } catch (e) { const msg = apiMessage(e) if (msg.conflict) setConflict(msg.message); else setError(msg.message) logger.warn('cc-exam-marker', 'Auto-map request failed', { templateId, message: msg.message }) } finally { if (!queued) setAutoMapBusy(false) } }, [applyTemplateToCanvas, autoMapBusy, pollAutoMapStatus, templateId]) 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 layoutSummary = useMemo(() => { const rows = (template?.layout ?? []).filter((row) => row.margins_enabled && row.margin_left !== null && row.margin_right !== null) return rows.slice(0, 4).map((row) => `P${row.page_index + 1} ${row.role ?? 'page'} L${Math.round(row.margin_left ?? 0)} R${Math.round(row.margin_right ?? 0)} T${Math.round(row.margin_top ?? 0)} B${Math.round(row.margin_bottom ?? 0)}`) }, [template?.layout]) const toolButtons = useMemo(() => TOOLS.map((tool) => ( )), [activeTool]) return ( t.zIndex.drawer + 20, bgcolor: 'background.default', display: 'flex', flexDirection: 'column' }}> {/* Top bar — single compact line */} navigate('/exam-marker')} size="small"> {template?.title ?? 'Template setup'} {(autoMapBusy || autoMapStatus) && } {/* Body row */} {/* Left tool sidebar */} {toolButtons} {/* Canvas area */} { editorRef.current = editor editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' }) editor.store.listen(() => setDirty(true), { scope: 'document' }) applyDocViewConstraints(editor, []) editor.resetZoom() // Only seed the example guide for a genuinely-empty template AFTER it has loaded. // (Previously `else seedGuide` fired on mount while `template` was still null during // the async fetch, flashing placeholder shapes before the real shapes/PDF rendered.) if (template) { const s = shapesFromTemplate(template, pageGeometriesRef.current) loadShapes(editor, s) if (!s.length) seedGuide(editor) } bringDomainShapesToFront(editor) }} /> {/* Guide toggle */} setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}> {/* Guide panel — collapsible */} Setup guide 1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) AI suggestions render dashed/translucent with confidence and cheap review flags; manual shapes stay solid. Review layer: {review.ai} AI suggestions, {review.unconfirmed} unconfirmed, {review.lowConfidence} below 70% confidence. Cheap flags include overlap, missing marks, uncertain labels, low confidence, and unconfirmed AI. {(['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. PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'} Margins: {layoutSummary.length ? layoutSummary.join(' · ') : 'not detected yet'} {/* Conflict alert */} {conflict && setConflict(null)}>{conflict}} {loading && } setError(null)}> setError(null)}>{error} ) } const ExamTemplateSetupPage: React.FC = () => ( Template setup canvas crashed. Reload the page and try again.}> ) export default ExamTemplateSetupPage