From dea3275f23d5f314eb0623dee4dd81be32f93d30 Mon Sep 17 00:00:00 2001 From: kcar Date: Sun, 7 Jun 2026 01:27:44 +0100 Subject: [PATCH] [verified] Add exam template setup canvas --- src/AppRoutes.tsx | 3 +- src/pages/exam/index.ts | 1 + .../exam/setup/ExamTemplateSetupPage.tsx | 230 ++++++++++++++++++ src/pages/exam/setup/examCanvasShapes.tsx | 89 +++++++ src/services/exam/examRepository.ts | 7 + src/types/exam.types.ts | 46 ++++ src/utils/exam-canvas/model.test.ts | 49 ++++ src/utils/exam-canvas/model.ts | 129 ++++++++++ 8 files changed, 553 insertions(+), 1 deletion(-) create mode 100644 src/pages/exam/setup/ExamTemplateSetupPage.tsx create mode 100644 src/pages/exam/setup/examCanvasShapes.tsx create mode 100644 src/utils/exam-canvas/model.test.ts create mode 100644 src/utils/exam-canvas/model.ts diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index f091bfb..3efa91d 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -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 = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/exam/index.ts b/src/pages/exam/index.ts index d8b8c5c..3169971 100644 --- a/src/pages/exam/index.ts +++ b/src/pages/exam/index.ts @@ -1 +1,2 @@ export { default as ExamDashboardPage } from './ExamDashboardPage'; +export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage'; diff --git a/src/pages/exam/setup/ExamTemplateSetupPage.tsx b/src/pages/exam/setup/ExamTemplateSetupPage.tsx new file mode 100644 index 0000000..21a11a6 --- /dev/null +++ b/src/pages/exam/setup/ExamTemplateSetupPage.tsx @@ -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(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({ isDarkMode: theme.palette.mode === 'dark' }) + 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 diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx new file mode 100644 index 0000000..d066514 --- /dev/null +++ b/src/pages/exam/setup/examCanvasShapes.tsx @@ -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 = { + 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 ( + +
+ + {shape.props.label || p.label} + + {!isBoundary && shape.props.questionId && Attached} +
+
+ ) +} + +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 { 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 { 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 { 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 +} diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index e7d8d99..e4c0913 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -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 { + const headers = await authHeaders(); + const res = await axios.put(`${EXAM_BASE}/templates/${templateId}`, payload, { headers }); + return res.data; + }, async archiveTemplate(templateId: string): Promise { const headers = await authHeaders(); await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers }); diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index 48312d4..2cc2bfc 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -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; + is_container?: boolean; + spec_ref?: string | null; + bounds?: Record | null; + page?: number | null; + }>; + response_areas: Array<{ + id?: string; + question_id: string; + page: number; + bounds: Record; + 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 | null; + source?: 'manual' | 'ai'; + confirmed?: boolean; + }>; +} diff --git a/src/utils/exam-canvas/model.test.ts b/src/utils/exam-canvas/model.test.ts new file mode 100644 index 0000000..2142dc9 --- /dev/null +++ b/src/utils/exam-canvas/model.test.ts @@ -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 }) + }) +}) diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts new file mode 100644 index 0000000..00ea3ae --- /dev/null +++ b/src/utils/exam-canvas/model.ts @@ -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): 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() + 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 +}