Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
seedGuide() ran in onMount via the 'else' branch while template was still null during the async fetch, creating 5 example shapes (Q1 start/end, part, response, context) that flashed on screen until the real template + PDF loaded. Only seed the guide for a genuinely-empty template after load. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
488 lines
26 KiB
TypeScript
488 lines
26 KiB
TypeScript
|
|
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<Editor | null>(null)
|
|
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
|
|
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 [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
|
const [pdfError, setPdfError] = useState<string | null>(null)
|
|
const [guideOpen, setGuideOpen] = useState(false)
|
|
const [autoMapStatus, setAutoMapStatus] = useState<AutoMapJobStatus | null>(null)
|
|
const [autoMapBusy, setAutoMapBusy] = useState(false)
|
|
const autoMapPollRef = useRef<number | null>(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) => (
|
|
<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" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>}
|
|
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', display: 'flex', flexDirection: 'column' }}>
|
|
|
|
{/* Top bar — single compact line */}
|
|
<Paper elevation={8} sx={{ px: 1.5, py: 0.75, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.paper', borderRadius: 0, flexShrink: 0 }}>
|
|
<Tooltip title="Back to exam marker">
|
|
<IconButton onClick={() => navigate('/exam-marker')} size="small"><ArrowBackIcon fontSize="small" /></IconButton>
|
|
</Tooltip>
|
|
<Divider orientation="vertical" flexItem />
|
|
<Typography variant="subtitle2" noWrap sx={{ flex: 1, minWidth: 0 }}>{template?.title ?? 'Template setup'}</Typography>
|
|
<Chip size="small" color={review.unconfirmed ? 'warning' : review.ai ? 'info' : 'default'} label={review.ai ? `AI review: ${review.unconfirmed} unconfirmed · ${review.lowConfidence} low conf` : 'Manual template'} />
|
|
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
|
{(autoMapBusy || autoMapStatus) && <Chip size="small" color={autoMapStatus?.status === 'failed' ? 'error' : autoMapStatus?.status === 'completed' ? 'success' : 'info'} label={autoMapStatusLabel(autoMapStatus)} />}
|
|
<Button size="small" variant="outlined" startIcon={autoMapBusy ? <CircularProgress size={14} /> : <AutoFixHighIcon fontSize="small" />} onClick={autoMapFromPdf} disabled={autoMapBusy || saving || loading || !template || pdfStatus !== 'ready'}>Auto-map from PDF</Button>
|
|
<Button size="small" variant="contained" startIcon={saving ? <CircularProgress size={14} color="inherit" /> : <SaveIcon fontSize="small" />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
|
</Paper>
|
|
|
|
{/* Body row */}
|
|
<Box sx={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
|
|
|
{/* Left tool sidebar */}
|
|
<Paper elevation={4} sx={{ width: 160, flexShrink: 0, p: 1.25, borderRadius: 0, bgcolor: 'background.paper', overflowY: 'auto', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
|
|
<Stack spacing={1}>{toolButtons}</Stack>
|
|
</Paper>
|
|
|
|
{/* Canvas area */}
|
|
<Box sx={{ flex: 1, position: 'relative', overflow: 'hidden' }} data-testid="exam-template-setup-canvas">
|
|
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }}>
|
|
<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' })
|
|
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)
|
|
}}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Guide toggle */}
|
|
<Tooltip title={guideOpen ? 'Hide guide' : 'Show setup guide'} placement="left">
|
|
<IconButton onClick={() => setGuideOpen((v) => !v)} size="small" sx={{ position: 'absolute', right: 16, bottom: 16, zIndex: 1001, bgcolor: 'background.paper', boxShadow: 2, '&:hover': { bgcolor: 'background.paper' } }}>
|
|
<HelpOutlineIcon fontSize="small" color={guideOpen ? 'primary' : 'action'} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
|
|
{/* Guide panel — collapsible */}
|
|
<Collapse in={guideOpen} sx={{ position: 'absolute', right: 16, bottom: 48, zIndex: 1000, maxWidth: 440 }}>
|
|
<Paper elevation={4} sx={{ p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
|
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
|
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.
|
|
</Typography>
|
|
<Alert severity={review.unconfirmed || review.lowConfidence ? 'warning' : 'info'} variant="outlined" sx={{ my: 1 }}>
|
|
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.
|
|
</Alert>
|
|
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
|
|
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
|
|
const p = canvasShapePalette[kind]
|
|
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
|
|
})}
|
|
</Stack>
|
|
<Divider sx={{ my: 1 }} />
|
|
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
|
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw "Q start" on page N, then "Q end" on a later page; save pairs boundaries by reading order into one question span.</Typography>
|
|
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
|
|
PDF: {pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ?? 'failed'}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
|
|
Margins: {layoutSummary.length ? layoutSummary.join(' · ') : 'not detected yet'}
|
|
</Typography>
|
|
</Paper>
|
|
</Collapse>
|
|
|
|
{/* Conflict alert */}
|
|
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 16, right: 16, maxWidth: 560, zIndex: 1001 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
|
</Box>
|
|
</Box>
|
|
|
|
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)', zIndex: 10 }}><CircularProgress /></Box>}
|
|
<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
|