Some checks failed
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 <noreply@anthropic.com>
230 lines
12 KiB
TypeScript
230 lines
12 KiB
TypeScript
|
||
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<Editor | null>(null)
|
||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [saving, setSaving] = useState(false)
|
||
const [dirty, setDirty] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [conflict, setConflict] = useState<string | null>(null)
|
||
const [activeTool, setActiveTool] = useState('select')
|
||
|
||
const 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) => (
|
||
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
||
<Button
|
||
size="small"
|
||
variant={activeTool === tool.id ? 'contained' : 'outlined'}
|
||
color={tool.color}
|
||
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : undefined}
|
||
onClick={() => {
|
||
const editor = editorRef.current
|
||
if (!editor) return
|
||
editor.setCurrentTool(tool.id === 'select' ? 'select' : tool.id)
|
||
setActiveTool(tool.id)
|
||
}}
|
||
sx={{ justifyContent: 'flex-start', minWidth: 126 }}
|
||
>
|
||
{tool.label}
|
||
</Button>
|
||
</Tooltip>
|
||
)), [activeTool])
|
||
|
||
return (
|
||
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default' }}>
|
||
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
|
||
<Tldraw
|
||
shapeUtils={examCanvasShapeUtils as any}
|
||
tools={examCanvasTools as any}
|
||
hideUi
|
||
inferDarkMode={theme.palette.mode === 'dark'}
|
||
autoFocus
|
||
onMount={(editor) => {
|
||
editorRef.current = editor
|
||
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||
if (template) loadShapes(editor, shapesFromTemplate(template)); else seedGuide(editor)
|
||
}}
|
||
/>
|
||
</Box>
|
||
|
||
<Paper elevation={8} sx={{ position: 'absolute', top: 12, left: 12, right: 12, px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1.5, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} size="small">Back</Button>
|
||
<Divider orientation="vertical" flexItem />
|
||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
|
||
<Typography variant="caption" color="text.secondary">Exam Marker › Setup · draw boundaries, part boxes, and regions; Save persists a full replace.</Typography>
|
||
</Box>
|
||
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||
<Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||
</Paper>
|
||
|
||
<Paper elevation={8} sx={{ position: 'absolute', top: 92, left: 12, p: 1.25, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||
<Stack spacing={1}>{toolButtons}</Stack>
|
||
</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="body2" color="text.secondary">
|
||
1) Draw top and bottom Boundary lines for each main question. 2) Draw Part boxes inside the pair. 3) Draw Response/Context/metadata regions inside a Part. Save derives parent links by spatial containment and reloads from the API.
|
||
</Typography>
|
||
</Paper>
|
||
|
||
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)' }}><CircularProgress /></Box>}
|
||
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 86, right: 16, maxWidth: 560 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
||
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
||
</Box>
|
||
)
|
||
}
|
||
|
||
const ExamTemplateSetupPage: React.FC = () => (
|
||
<ErrorBoundary fallback={<Box sx={{ p: 4 }}><Alert severity="error">Template setup canvas crashed. Reload the page and try again.</Alert></Box>}>
|
||
<ExamTemplateSetupInner />
|
||
</ErrorBoundary>
|
||
)
|
||
|
||
export default ExamTemplateSetupPage
|