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 }