merge: ExamCanvas core setup page /exam-marker/:templateId/setup (S4-9a)
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
This commit is contained in:
commit
496ec2cbf9
@ -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 } from './pages/exam';
|
||||
import { ExamDashboardPage, ExamTemplateSetupPage } 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 = () => {
|
||||
<Route path="/search" element={<SearxngPage />} />
|
||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
||||
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||
<Route path="/morphic" element={<MorphicPage />} />
|
||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||
|
||||
230
src/pages/exam/setup/ExamTemplateSetupPage.tsx
Normal file
230
src/pages/exam/setup/ExamTemplateSetupPage.tsx
Normal file
@ -0,0 +1,230 @@
|
||||
|
||||
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, defaultBindingUtils, 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: 'default' 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: 'default' 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}
|
||||
bindingUtils={defaultBindingUtils}
|
||||
tools={examCanvasTools as any}
|
||||
hideUi
|
||||
inferDarkMode={theme.palette.mode === 'dark'}
|
||||
autoFocus
|
||||
onMount={(editor) => {
|
||||
editorRef.current = editor
|
||||
editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' })
|
||||
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
|
||||
89
src/pages/exam/setup/examCanvasShapes.tsx
Normal file
89
src/pages/exam/setup/examCanvasShapes.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
|
||||
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 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 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
|
||||
}
|
||||
}
|
||||
|
||||
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: string }> = {
|
||||
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' },
|
||||
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' },
|
||||
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' },
|
||||
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' },
|
||||
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' },
|
||||
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' },
|
||||
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' },
|
||||
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' },
|
||||
}
|
||||
|
||||
function renderShape(shape: ExamCanvasTLShape) {
|
||||
const kind = shape.props.kind
|
||||
const p = palette[kind] ?? palette.response
|
||||
const isBoundary = kind === 'boundary'
|
||||
return (
|
||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||||
<div style={{
|
||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid ${p.stroke}`,
|
||||
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||
background: isBoundary ? 'transparent' : p.fill, color: p.stroke, fontFamily: 'Inter, system-ui, sans-serif',
|
||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
||||
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? 'none' : '0 10px 22px rgba(15,23,42,0.08)', overflow: 'hidden'
|
||||
}}>
|
||||
<span style={{ fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.6, background: 'rgba(255,255,255,0.84)', borderRadius: 999, padding: '2px 7px' }}>
|
||||
{shape.props.label || p.label}
|
||||
</span>
|
||||
{!isBoundary && shape.props.questionId && <span style={{ fontSize: 11, fontWeight: 700, opacity: .75 }}>Attached</span>}
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||
const p = palette[kind]
|
||||
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
||||
}
|
||||
|
||||
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = { 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) }; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
||||
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
||||
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(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 = [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 shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
|
||||
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
|
||||
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
|
||||
}
|
||||
@ -17,6 +17,7 @@ import type {
|
||||
ExamResponseArea,
|
||||
ExamTemplate,
|
||||
ExamTemplateDetail,
|
||||
TemplateReplacePayload,
|
||||
UpdateTemplateMetaPayload,
|
||||
} from '../../types/exam.types';
|
||||
|
||||
@ -164,6 +165,12 @@ export const examRepository = {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
|
||||
const headers = await authHeaders();
|
||||
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
async archiveTemplate(templateId: string): Promise<void> {
|
||||
const headers = await authHeaders();
|
||||
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||
|
||||
@ -94,3 +94,49 @@ export interface ExamTemplateDetail extends ExamTemplate {
|
||||
response_areas: ExamResponseArea[];
|
||||
boundaries: ExamBoundary[];
|
||||
}
|
||||
|
||||
|
||||
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?: Record<string, unknown>;
|
||||
is_container?: boolean;
|
||||
spec_ref?: string | null;
|
||||
bounds?: Record<string, number> | null;
|
||||
page?: number | null;
|
||||
}>;
|
||||
response_areas: Array<{
|
||||
id?: string;
|
||||
question_id: string;
|
||||
page: number;
|
||||
bounds: Record<string, number>;
|
||||
kind: ExamResponseArea['kind'];
|
||||
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<string, number> | null;
|
||||
source?: 'manual' | 'ai';
|
||||
confirmed?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
49
src/utils/exam-canvas/model.test.ts
Normal file
49
src/utils/exam-canvas/model.test.ts
Normal file
@ -0,0 +1,49 @@
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ExamTemplateDetail } from '../../types/exam.types'
|
||||
import { isUuid, 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('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 })
|
||||
})
|
||||
})
|
||||
129
src/utils/exam-canvas/model.ts
Normal file
129
src/utils/exam-canvas/model.ts
Normal file
@ -0,0 +1,129 @@
|
||||
|
||||
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
|
||||
|
||||
export const PAGE_HEIGHT = 1100
|
||||
export const PAGE_WIDTH = 780
|
||||
|
||||
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): number {
|
||||
return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
|
||||
}
|
||||
|
||||
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<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): 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[]): TemplateReplacePayload {
|
||||
const orderedBoundaries = shapes
|
||||
.filter((s) => s.kind === 'boundary')
|
||||
.sort((a, b) => (pageForY(a.y) - pageForY(b.y)) || (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: pageForY(b.y) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
|
||||
}
|
||||
}
|
||||
|
||||
const partQuestionIds = new Map<string, string>()
|
||||
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: pageForY(part.y) })
|
||||
})
|
||||
|
||||
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) => pageForY(part.y) === pageForY(region.y)) ?? 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: pageForY(region.y), 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): 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: (r.page - 1) * PAGE_HEIGHT + 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 ?? (r.page - 1) * PAGE_HEIGHT + 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user