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 { ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes' const TOOLS = [ { 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.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.context, label: 'Context', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' 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.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const }, { id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'inherit' as const }, ] 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 seedGuide(editor: Editor) { const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)) if (current.length) return editor.createShapes([ { id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } }, { id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end', domainId: newDomainId() } }, { id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } }, { id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } }, ]) } const ExamTemplateSetupInner: React.FC = () => { const { templateId } = useParams<{ templateId: string }>() const navigate = useNavigate() const theme = useTheme() const editorRef = useRef(null) 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 load = useCallback(async () => { if (!templateId) return setLoading(true); setError(null); setConflict(null) try { const detail = await examRepository.getTemplate(templateId) setTemplate(detail) const editor = editorRef.current if (editor) loadShapes(editor, shapesFromTemplate(detail)) 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) const saved = await examRepository.replaceTemplate(templateId, payload) setTemplate(saved) loadShapes(editor, shapesFromTemplate(saved)) 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)); else seedGuide(editor) }} /> {template?.title ?? 'Template setup'} Exam Marker › Setup · draw boundaries, part boxes, and regions; Save persists a full replace. {toolButtons} Setup guide 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. {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