Compare commits

..
Author SHA1 Message Date
kcar 3eac792ced feat(exam-setup): UX polish — icons, dark/light palette tokens, multi-page boundary hint 2026-06-07 05:10:11 +01:00
CC WorkerandClaude Sonnet 4.6 8e8a345e61 fix: incremental PDF page rendering for exam setup backdrop
app-ci-deploy / test-build-deploy (push) Has been cancelled
Rendering a 36-page AQA exam PDF sequentially created 36 large canvas
elements, causing memory pressure and keeping pdfStatus='loading' for
60–120s in headless Chrome. Two changes:

1. pdfLoader.ts: reuse a single canvas (reduces peak memory from ~120MB
   to ~4MB) and fire onPageReady callback after each page so callers can
   stream pages to the canvas as they render.

2. ExamTemplateSetupPage.tsx: use the callback to add each PDF page
   shape to the tldraw canvas the moment it renders, making the first
   page visible within a few seconds rather than after all pages load.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 03:35:09 +00:00
CC WorkerandClaude Sonnet 4.6 2de3e29179 fix: serve .mjs files as application/javascript for pdfjs module worker
app-ci-deploy / test-build-deploy (push) Has been cancelled
nginx:alpine mime.types only covers .js, not .mjs. The pdfjs-dist v4
worker is output as pdf.worker-*.mjs; without the correct MIME type the
browser refuses to execute it as a module worker and pdfjs throws
'Network Error', blocking the PDF backdrop from rendering.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 03:21:44 +00:00
CC WorkerandClaude Sonnet 4.6 29390d30ca Merge S4-9b: PDF backdrop on ExamCanvas from source-pdf endpoint
app-ci-deploy / test-build-deploy (push) Has been cancelled
Renders template source PDFs as locked image shapes behind the exam
setup regions. Adds page geometry abstraction so shape coordinates
map to real PDF page dimensions rather than fixed PAGE_HEIGHT math.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 02:59:17 +00:00
kcar aa2f35e467 feat(exam): render template PDFs behind setup canvas 2026-06-07 03:56:09 +01:00
CC WorkerandClaude Sonnet 4.6 61a189a7a2 fix: tldraw user prefs colorScheme and indicator method for shape utils
app-ci-deploy / test-build-deploy (push) Has been cancelled
- updateUserPreferences: isDarkMode → colorScheme ('dark'|'light') per
  tldraw 3.6.1 TLUserPreferences type (isDarkMode is read-only computed)
- BaseBoxShapeUtil subclasses: add required indicator() method returning
  bounding rect; fixes non-abstract class missing abstract member error

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 02:30:57 +00:00
CC WorkerandClaude Sonnet 4.6 ab6f0b09d7 fix: remove duplicate bindingUtils causing tldraw arrow double-registration
app-ci-deploy / test-build-deploy (push) Has been cancelled
Tldraw 3.6.1 Tldraw component already registers defaultBindingUtils
internally; passing them explicitly via the bindingUtils prop caused
"Binding type 'arrow' is defined more than once" runtime crash.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-07 02:21:32 +00:00
CC WorkerandClaude Opus 4.8 16ae3aa089 fix(exam): setup toolbar crash — MUI Button color 'default' is invalid in v5
app-ci-deploy / test-build-deploy (push) Has been cancelled
Select + Furniture tool buttons used color='default', which MUI v5 resolves to
theme.palette.default (undefined) -> TypeError reading 'main'/'dark' at render,
tripping the ErrorBoundary ('Template setup canvas crashed'). Use 'inherit'.

Caught by live browser-verify t_11f5a049; model-level tests never rendered the toolbar.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 01:03:08 +00:00
CC Worker 496ec2cbf9 merge: ExamCanvas core setup page /exam-marker/:templateId/setup (S4-9a)
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 00:54:55 +00:00
8 changed files with 299 additions and 61 deletions
+3
View File
@@ -19,6 +19,9 @@ FROM nginx:alpine
# Copy built files # Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# .mjs files (pdfjs worker) must be served as application/javascript for module workers
RUN sed -i 's|application/javascript\s*js;|application/javascript js mjs;|' /etc/nginx/mime.types
# Create a simple nginx configuration # Create a simple nginx configuration
RUN echo 'server { \ RUN echo 'server { \
listen 3000; \ listen 3000; \
+4 -1
View File
@@ -31,7 +31,10 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> })); vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> })); vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> })); vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> })); vi.mock('./pages/exam', () => ({
ExamDashboardPage: () => <div>Exam Marker</div>,
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
}));
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> })); vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> })); vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> })); vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
+105 -24
View File
@@ -6,28 +6,41 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import SaveIcon from '@mui/icons-material/Save' import SaveIcon from '@mui/icons-material/Save'
import MouseIcon from '@mui/icons-material/Mouse' import MouseIcon from '@mui/icons-material/Mouse'
import '@tldraw/tldraw/tldraw.css' import '@tldraw/tldraw/tldraw.css'
import { Editor, Tldraw, createShapeId, defaultBindingUtils, TLShape } from '@tldraw/tldraw' import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
import axios from 'axios' import axios from 'axios'
import { ErrorBoundary } from '../../../components/ErrorBoundary' import { ErrorBoundary } from '../../../components/ErrorBoundary'
import { logger } from '../../../debugConfig' import { logger } from '../../../debugConfig'
import { examRepository } from '../../../services/exam/examRepository' import { examRepository } from '../../../services/exam/examRepository'
import type { ExamTemplateDetail } from '../../../types/exam.types' import type { ExamTemplateDetail } from '../../../types/exam.types'
import { ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model' import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes' import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
const TOOLS = [ const TOOLS = [
{ id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'default' as const }, { 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', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' 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', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' 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', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' 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', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' 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', tip: 'Box the printed question number.', color: 'success' 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', tip: 'Box printed marks such as [2].', 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', tip: 'Box student resources/reference material.', color: 'info' 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', tip: 'Mark margins/page numbers/ignored decoration.', color: 'default' 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 = 260
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
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 apiMessage(err: unknown): { message: string; conflict: boolean } { function apiMessage(err: unknown): { message: string; conflict: boolean } {
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
const detail = (err.response?.data as { detail?: string } | undefined)?.detail const detail = (err.response?.data as { detail?: string } | undefined)?.detail
@@ -88,14 +101,35 @@ function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
}))) })))
} }
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
}))
const ids = geometries.map((geometry) => createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber))
try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ }
}
function seedGuide(editor: Editor) { function seedGuide(editor: Editor) {
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)) const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
if (current.length) return if (current.length) return
editor.createShapes([ 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: 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.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.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.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() } },
]) ])
} }
@@ -104,6 +138,7 @@ const ExamTemplateSetupInner: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const theme = useTheme() const theme = useTheme()
const editorRef = useRef<Editor | null>(null) const editorRef = useRef<Editor | null>(null)
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null) const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -111,6 +146,8 @@ const ExamTemplateSetupInner: React.FC = () => {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [conflict, setConflict] = useState<string | null>(null) const [conflict, setConflict] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState('select') const [activeTool, setActiveTool] = useState('select')
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
const [pdfError, setPdfError] = useState<string | null>(null)
const load = useCallback(async () => { const load = useCallback(async () => {
if (!templateId) return if (!templateId) return
@@ -118,8 +155,40 @@ const ExamTemplateSetupInner: React.FC = () => {
try { try {
const detail = await examRepository.getTemplate(templateId) const detail = await examRepository.getTemplate(templateId)
setTemplate(detail) 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])
try { ed.sendToBack([shapeId as any]) } catch { /* */ }
}
}
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 const editor = editorRef.current
if (editor) loadShapes(editor, shapesFromTemplate(detail)) if (editor) {
syncPdfPages(editor, pages)
loadShapes(editor, shapesFromTemplate(detail, geometries))
}
setDirty(false) setDirty(false)
} catch (e) { } catch (e) {
const msg = apiMessage(e).message const msg = apiMessage(e).message
@@ -139,10 +208,10 @@ const ExamTemplateSetupInner: React.FC = () => {
try { try {
ensureDomainIds(editor) ensureDomainIds(editor)
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[] const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
const payload = serializeCanvasShapes(template, shapes) const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
const saved = await examRepository.replaceTemplate(templateId, payload) const saved = await examRepository.replaceTemplate(templateId, payload)
setTemplate(saved) setTemplate(saved)
loadShapes(editor, shapesFromTemplate(saved)) loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
setDirty(false) setDirty(false)
} catch (e) { } catch (e) {
const msg = apiMessage(e) const msg = apiMessage(e)
@@ -159,7 +228,7 @@ const ExamTemplateSetupInner: React.FC = () => {
size="small" size="small"
variant={activeTool === tool.id ? 'contained' : 'outlined'} variant={activeTool === tool.id ? 'contained' : 'outlined'}
color={tool.color} color={tool.color}
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : undefined} startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>}
onClick={() => { onClick={() => {
const editor = editorRef.current const editor = editorRef.current
if (!editor) return if (!editor) return
@@ -178,16 +247,15 @@ const ExamTemplateSetupInner: React.FC = () => {
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas"> <Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
<Tldraw <Tldraw
shapeUtils={examCanvasShapeUtils as any} shapeUtils={examCanvasShapeUtils as any}
bindingUtils={defaultBindingUtils}
tools={examCanvasTools as any} tools={examCanvasTools as any}
hideUi hideUi
inferDarkMode={theme.palette.mode === 'dark'} inferDarkMode={theme.palette.mode === 'dark'}
autoFocus autoFocus
onMount={(editor) => { onMount={(editor) => {
editorRef.current = editor editorRef.current = editor
editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' }) editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
editor.store.listen(() => setDirty(true), { scope: 'document' }) editor.store.listen(() => setDirty(true), { scope: 'document' })
if (template) loadShapes(editor, shapesFromTemplate(template)); else seedGuide(editor) if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
}} }}
/> />
</Box> </Box>
@@ -197,7 +265,7 @@ const ExamTemplateSetupInner: React.FC = () => {
<Divider orientation="vertical" flexItem /> <Divider orientation="vertical" flexItem />
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography> <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> <Typography variant="caption" color="text.secondary">Exam Marker Setup · coloured tools map to persisted regions; boundary start/end pairs can span pages.</Typography>
</Box> </Box>
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} /> <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> <Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
@@ -207,10 +275,23 @@ const ExamTemplateSetupInner: React.FC = () => {
<Stack spacing={1}>{toolButtons}</Stack> <Stack spacing={1}>{toolButtons}</Stack>
</Paper> </Paper>
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 420, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}> <Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 460, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography> <Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
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. 1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment.
</Typography>
<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="text.secondary" display="block" sx={{ mt: 0.75 }}>Open design choices resolved for v1: labels use Q start/end; persistent Attached pills confirm containment; rectangles stay simple for dense multi-column papers; Back button remains explicit.</Typography>
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'}
</Typography> </Typography>
</Paper> </Paper>
+80 -22
View File
@@ -3,6 +3,8 @@ import React from 'react'
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw' import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model' import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
export const SHAPE_TYPES = { export const SHAPE_TYPES = {
boundary: 'exam-boundary', boundary: 'exam-boundary',
part: 'exam-part', part: 'exam-part',
@@ -14,6 +16,11 @@ export const SHAPE_TYPES = {
furniture: 'exam-region-furniture', furniture: 'exam-region-furniture',
} as const } as const
export type ExamPdfPageTLShape = TLBaseBoxShape & {
type: typeof PDF_PAGE_SHAPE_TYPE
props: { w: number; h: number; src: string; pageNumber: number }
}
export type ExamCanvasTLShape = TLBaseBoxShape & { export type ExamCanvasTLShape = TLBaseBoxShape & {
type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES] type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
props: { props: {
@@ -29,47 +36,94 @@ export type ExamCanvasTLShape = TLBaseBoxShape & {
} }
} }
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: string }> = { type CanvasPaletteEntry = {
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' }, stroke: string
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' }, fill: string
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' }, darkStroke: string
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' }, darkFill: string
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' }, dash?: string
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' }, label: string
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' }, icon: string
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' }, role: string
} }
export const canvasShapePalette: Record<ExamCanvasShapeKind, CanvasPaletteEntry> = {
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', darkStroke: '#f87171', darkFill: 'rgba(248,113,113,0.10)', dash: '8 6', label: 'Boundary', icon: '↕', role: 'start/end rule' },
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.18)', darkStroke: '#fbbf24', darkFill: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', role: 'markable box' },
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.18)', darkStroke: '#60a5fa', darkFill: 'rgba(96,165,250,0.34)', label: 'Response', icon: '✎', role: 'student writing' },
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', darkStroke: '#a78bfa', darkFill: 'rgba(167,139,250,0.28)', dash: '6 5', label: 'Context', icon: '◉', role: 'stimulus' },
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', darkStroke: '#2dd4bf', darkFill: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', role: 'printed label' },
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', darkStroke: '#4ade80', darkFill: 'rgba(74,222,128,0.23)', label: 'Marks', icon: '[2]', role: 'printed marks' },
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', darkStroke: '#22d3ee', darkFill: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '§', role: 'resource' },
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' },
}
const shapeCss = `
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
`
function renderShape(shape: ExamCanvasTLShape) { function renderShape(shape: ExamCanvasTLShape) {
const kind = shape.props.kind const kind = shape.props.kind
const p = palette[kind] ?? palette.response const p = canvasShapePalette[kind] ?? canvasShapePalette.response
const isBoundary = kind === 'boundary' const isBoundary = kind === 'boundary'
return ( return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}> <HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
<div style={{ <style>{shapeCss}</style>
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid ${p.stroke}`, <div
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
style={{
'--exam-light-stroke': p.stroke,
'--exam-light-fill': p.fill,
'--exam-dark-stroke': p.darkStroke,
'--exam-dark-fill': p.darkFill,
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10, borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
background: isBoundary ? 'transparent' : p.fill, color: p.stroke, fontFamily: 'Inter, system-ui, sans-serif', background: isBoundary ? 'transparent' : 'var(--exam-fill)', color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between', 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' padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
}}> } as React.CSSProperties}
<span style={{ fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.6, background: 'rgba(255,255,255,0.84)', borderRadius: 999, padding: '2px 7px' }}> aria-label={`${p.label}: ${p.role}`}
title={`${p.label}: ${p.role}`}
>
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
<span aria-hidden="true">{p.icon}</span>
{shape.props.label || p.label} {shape.props.label || p.label}
</span> </span>
{!isBoundary && shape.props.questionId && <span style={{ fontSize: 11, fontWeight: 700, opacity: .75 }}>Attached</span>} {!isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
</div> </div>
</HTMLContainer> </HTMLContainer>
) )
} }
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
const p = palette[kind] const p = canvasShapePalette[kind]
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined } 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) } } const sharedProps = { 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) }
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) } } const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
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 PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
static override type = PDF_PAGE_SHAPE_TYPE
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
override canEdit() { return false }
override component(shape: ExamPdfPageTLShape) {
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
<img src={shape.props.src} alt={'PDF page ' + shape.props.pageNumber} draggable={false} style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none', boxShadow: '0 2px 16px rgba(15,23,42,0.18)', background: '#fff' }} />
</HTMLContainer>
)
}
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
}
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary } 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 PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
@@ -80,9 +134,13 @@ class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.m
class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference } 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 } 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 examCanvasShapeUtils = [PdfPageUtil, 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 const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const
export function isPdfPageShape(type: string): boolean {
return type === PDF_PAGE_SHAPE_TYPE
}
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null { export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type) const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
+47
View File
@@ -0,0 +1,47 @@
import * as pdfjsLib from "pdfjs-dist"
import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url"
import { PAGE_WIDTH } from "../../../utils/exam-canvas/model"
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc
export interface PdfPageImage {
pageNumber: number
src: string
width: number
height: number
}
export async function loadPdfPageImages(
pdfBytes: ArrayBuffer,
targetWidth = PAGE_WIDTH,
onPageReady?: (pages: PdfPageImage[]) => void,
): Promise<PdfPageImage[]> {
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise
const pages: PdfPageImage[] = []
// Reuse a single canvas across all pages to avoid allocating ~120 MB of canvas memory
// for a typical 36-page exam paper.
const canvas = document.createElement("canvas")
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
const page = await pdf.getPage(pageNumber)
const baseViewport = page.getViewport({ scale: 1 })
const scale = targetWidth / baseViewport.width
const viewport = page.getViewport({ scale })
canvas.width = Math.ceil(viewport.width)
canvas.height = Math.ceil(viewport.height)
const context = canvas.getContext("2d")
if (!context) throw new Error("Unable to create PDF render canvas")
context.clearRect(0, 0, canvas.width, canvas.height)
await page.render({ canvasContext: context, viewport }).promise
pages.push({
pageNumber,
src: canvas.toDataURL("image/png"),
width: canvas.width,
height: canvas.height,
})
onPageReady?.([...pages])
}
return pages
}
+9
View File
@@ -127,6 +127,15 @@ export const examRepository = {
return res.data; return res.data;
}, },
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
const headers = await authHeaders();
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
headers,
responseType: 'arraybuffer',
});
return res.data;
},
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> { async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
const headers = await authHeaders(); const headers = await authHeaders();
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers }); const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
+18 -2
View File
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { ExamTemplateDetail } from '../../types/exam.types' import type { ExamTemplateDetail } from '../../types/exam.types'
import { isUuid, serializeCanvasShapes, shapesFromTemplate } from './model' import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model'
const template: ExamTemplateDetail = { const template: ExamTemplateDetail = {
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1, id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
@@ -30,6 +29,23 @@ describe('exam setup canvas serialization', () => {
expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true) expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true)
}) })
it('maps shapes to the visible PDF page geometry rather than a fixed page height', () => {
const pages = [
{ pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 },
{ pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 },
]
expect(pageForY(1050, pages)).toBe(2)
const payload = serializeCanvasShapes(template, [
{ id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' },
{ id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' },
{ id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' },
{ id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 },
], pages)
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
expect(payload.response_areas[0].page).toBe(2)
})
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => { it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
const shapes = shapesFromTemplate({ const shapes = shapesFromTemplate({
...template, ...template,
+31 -10
View File
@@ -3,6 +3,9 @@ import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exa
export const PAGE_HEIGHT = 1100 export const PAGE_HEIGHT = 1100
export const PAGE_WIDTH = 780 export const PAGE_WIDTH = 780
export const PAGE_GAP = 0
export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number }
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture' export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
@@ -25,10 +28,28 @@ export interface ExamCanvasShapeModel {
questionId?: string | null questionId?: string | null
} }
export function pageForY(y: number): number { export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
if (pages?.length) {
const hit = pages.find((page) => y >= page.y && y <= page.y + page.h)
if (hit) return hit.pageNumber
const nearest = pages.reduce((best, page) => {
const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h)))
return dy < best.dy ? { page, dy } : best
}, { page: pages[0], dy: Number.POSITIVE_INFINITY })
return nearest.page.pageNumber
}
return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1) return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
} }
export function pageTop(page: number, pages?: CanvasPageGeometry[]): number {
const hit = pages?.find((p) => p.pageNumber === page)
return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP))
}
function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): number {
return pageForY(shape.y + shape.h / 2, pages)
}
export function isUuid(value: string | null | undefined): value is string { 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) 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)
} }
@@ -63,10 +84,10 @@ function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, s
return cy >= minY && cy <= maxY return cy >= minY && cy <= maxY
} }
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[]): TemplateReplacePayload { export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload {
const orderedBoundaries = shapes const orderedBoundaries = shapes
.filter((s) => s.kind === 'boundary') .filter((s) => s.kind === 'boundary')
.sort((a, b) => (pageForY(a.y) - pageForY(b.y)) || (a.y - b.y)) .sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y))
const parts = shapes.filter((s) => s.kind === 'part') const parts = shapes.filter((s) => s.kind === 'part')
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part') const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
@@ -84,7 +105,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} }) questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
bands.push({ questionId, top, bottom }) bands.push({ questionId, top, bottom })
for (const b of [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 }) boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
} }
} }
@@ -93,23 +114,23 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part)) const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId() const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
partQuestionIds.set(part.id, qid) 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) }) 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: pageForShape(part, pages) })
}) })
const response_areas: TemplateReplacePayload['response_areas'] = [] const response_areas: TemplateReplacePayload['response_areas'] = []
for (const region of regions) { for (const region of regions) {
const containingPart = parts.find((part) => contains(bounds(part), bounds(region))) const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
const fallbackPart = parts.find((part) => pageForY(part.y) === pageForY(region.y)) ?? parts[0] const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
if (!questionId) continue if (!questionId) continue
const kind = region.kind as ExamCanvasRegionKind 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 }) response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), 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 } 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[] { export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
const shapes: ExamCanvasShapeModel[] = [] const shapes: ExamCanvasShapeModel[] = []
const questions = new Map(detail.questions.map((q) => [q.id, q])) const questions = new Map(detail.questions.map((q) => [q.id, q]))
for (const b of detail.boundaries ?? []) { for (const b of detail.boundaries ?? []) {
@@ -121,9 +142,9 @@ export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeM
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 }) 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 ?? []) { 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 bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
const q = questions.get(r.question_id) 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 }) shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 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 return shapes
} }