Compare commits

..
Author SHA1 Message Date
kcar d4a1d29298 chore: preserve app workspace t_61fb4c27_clone 2026-08-09 20:24:44 +01: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
kcar dea3275f23 [verified] Add exam template setup canvas 2026-06-07 01:27:44 +01:00
CC Worker f067db3eb8 Merge remote-tracking branch 'origin/agent/s4-8-2-template-versioning'
app-ci-deploy / test-build-deploy (push) Has been cancelled
2026-06-07 00:12:25 +00:00
kcar fdbc19cf0d feat(exam): version templates on dashboard 2026-06-07 00:33:06 +01:00
16 changed files with 1296 additions and 579 deletions
+3
View File
@@ -19,6 +19,9 @@ FROM nginx:alpine
# Copy built files
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
RUN echo 'server { \
listen 3000; \
+60
View File
@@ -0,0 +1,60 @@
const { chromium } = require('playwright');
const fs = require('fs');
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
const EMAIL = process.env.VITE_TEST_TEACHER_EMAIL || '[email protected]';
const PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD || process.env.SEED_TEACHER_PASSWORD;
const TEMPLATE_ID = process.env.TEMPLATE_ID || '31d92cf3-9bbd-4a7e-b2dc-b37f8b69bc34';
const OUT = process.env.OUT || '/out';
async function run(mode) {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ colorScheme: mode, viewport: { width: 1440, height: 980 } });
const page = await context.newPage();
const messages = [];
page.on('console', msg => messages.push(`${msg.type()}: ${msg.text()}`));
page.on('pageerror', err => messages.push(`pageerror: ${err.message}`));
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
if (await page.getByLabel('Email').count()) {
if (!PASSWORD) throw new Error('missing password env');
await page.getByLabel('Email').fill(EMAIL);
await page.getByLabel('Password').fill(PASSWORD);
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL(url => /\/dashboard|\/exam-marker|\/node\//.test(url.pathname), { timeout: 20000 });
}
await page.goto(`${BASE}/exam-marker/${TEMPLATE_ID}/setup`, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('[data-testid="exam-template-setup-canvas"]', { timeout: 20000 });
await page.waitForTimeout(3500);
const text = await page.locator('body').innerText();
const checks = {
hasBoundaryTool: text.includes('Boundary'),
hasPartTool: text.includes('Part'),
hasResponseTool: text.includes('Response'),
hasContextTool: text.includes('Context'),
hasQuestionNumberTool: text.includes('Q Number'),
hasMarkAreaTool: text.includes('Mark Area'),
hasReferenceTool: text.includes('Reference'),
hasFurnitureTool: text.includes('Furniture'),
hasHintPanel: text.includes('Setup guide') && text.includes('Boundary pairing preview'),
hasMultiPageCopy: text.includes('later page') || text.includes('multi-page'),
hasPdfStatus: text.includes('PDF backdrop:'),
crashOverlay: text.includes('Template setup canvas crashed'),
};
await page.screenshot({ path: `${OUT}/s4-9c-${mode}.png`, fullPage: true });
await browser.close();
return { mode, url: `${BASE}/exam-marker/${TEMPLATE_ID}/setup`, checks, consoleMessages: messages };
}
(async () => {
fs.mkdirSync(OUT, { recursive: true });
const results = [];
for (const mode of ['light', 'dark']) results.push(await run(mode));
fs.writeFileSync(`${OUT}/s4-9c-smoke.json`, JSON.stringify(results, null, 2));
const failures = results.flatMap(r => Object.entries(r.checks).filter(([k,v]) => k === 'crashOverlay' ? v : !v).map(([k]) => `${r.mode}:${k}`));
const consoleErrors = results.flatMap(r => r.consoleMessages.filter(m => /^(error|pageerror):/i.test(m) && !/Failed to load resource/i.test(m)));
console.log(JSON.stringify({ results, failures, consoleErrors }, null, 2));
if (failures.length || consoleErrors.length) process.exit(1);
})().catch(err => { console.error(err); process.exit(1); });
+4 -2
View File
@@ -31,11 +31,13 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</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/settingsPage', () => ({ default: () => <div>Settings</div> }));
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
vi.mock('./pages/tldraw/ExamMarkerSpikePage', () => ({ default: () => <div>Exam Marker Spike</div> }));
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
+2 -3
View File
@@ -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';
@@ -16,7 +16,6 @@ import AdminDashboard from './pages/auth/adminPage';
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
import DevPage from './pages/tldraw/devPage';
import ExamMarkerSpikePage from './pages/tldraw/ExamMarkerSpikePage';
import TeacherPlanner from './pages/react-flow/teacherPlanner';
import MorphicPage from './pages/morphicPage';
import NotFound from './pages/user/NotFound';
@@ -184,10 +183,10 @@ 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 />} />
<Route path="/exam-marker-spike" element={<ExamMarkerSpikePage />} />
<Route path="/dev" element={<DevPage />} />
<Route path="/dev/upload-test" element={<SimpleUploadTest />} />
<Route path="/single-player" element={<SinglePlayerPage />} />
+224 -96
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
@@ -22,6 +22,8 @@ import {
import AddIcon from '@mui/icons-material/Add';
import ArchiveIcon from '@mui/icons-material/Archive';
import AssignmentIcon from '@mui/icons-material/Assignment';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import EditIcon from '@mui/icons-material/Edit';
import { useAuth } from '../../contexts/AuthContext';
import { examRepository } from '../../services/exam/examRepository';
@@ -34,6 +36,51 @@ const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> =
archived: 'default',
};
const VERSION_SEPARATOR = ' · ';
const VERSION_RE = /(?:^|\s)[vV](\d+(?:\.\d+)*)$/;
type DialogMode = 'create' | 'edit' | 'duplicate';
type TemplateDialogState = {
mode: DialogMode;
template?: ExamTemplate;
} | null;
function splitTemplateTitle(title: string): { name: string; version: string } {
const parts = title.split(VERSION_SEPARATOR);
const possibleVersion = parts[parts.length - 1]?.trim() ?? '';
if (parts.length > 1 && VERSION_RE.test(possibleVersion)) {
return { name: parts.slice(0, -1).join(VERSION_SEPARATOR).trim(), version: possibleVersion };
}
return { name: title, version: 'v1' };
}
function composeTemplateTitle(name: string, version: string): string {
const cleanName = name.trim();
const cleanVersion = version.trim();
return cleanVersion ? `${cleanName}${VERSION_SEPARATOR}${cleanVersion}` : cleanName;
}
function nextVersionLabel(version: string): string {
const match = version.trim().match(VERSION_RE);
if (!match) return 'v2';
const segments = match[1].split('.');
const last = Number(segments[segments.length - 1]);
segments[segments.length - 1] = Number.isFinite(last) ? String(last + 1) : '2';
return `v${segments.join('.')}`;
}
function paperKey(t: ExamTemplate): string {
return t.exam_id ?? t.source_file_id ?? t.exam_code ?? t.subject ?? 'custom-paper';
}
function paperLabel(t: ExamTemplate): string {
if (t.exam_code) return t.exam_code;
if (t.subject) return t.subject;
if (t.source_file_id) return 'Uploaded paper';
return 'Custom paper';
}
const ExamDashboardPage: React.FC = () => {
const navigate = useNavigate();
const { bootstrapData } = useAuth();
@@ -43,10 +90,10 @@ const ExamDashboardPage: React.FC = () => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [title, setTitle] = useState('');
const [dialog, setDialog] = useState<TemplateDialogState>(null);
const [templateName, setTemplateName] = useState('');
const [version, setVersion] = useState('v1');
const [subject, setSubject] = useState('');
const [sourcePdf, setSourcePdf] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
@@ -67,47 +114,99 @@ const ExamDashboardPage: React.FC = () => {
void load();
}, [load, instituteId]);
const handleOpenCreate = () => {
setCreateOpen(true);
};
const groupedTemplates = useMemo(() => {
const groups = new Map<string, { label: string; templates: ExamTemplate[] }>();
templates.forEach((template) => {
const key = paperKey(template);
const existing = groups.get(key);
if (existing) {
existing.templates.push(template);
} else {
groups.set(key, { label: paperLabel(template), templates: [template] });
}
});
return Array.from(groups.entries()).map(([key, group]) => ({ key, ...group }));
}, [templates]);
const handleCloseCreate = () => {
if (saving) return;
setCreateOpen(false);
setTitle('');
const openCreate = () => {
setTemplateName('New template');
setVersion('v1');
setSubject('');
setSourcePdf(null);
setDialog({ mode: 'create' });
};
const handleCreate = async () => {
if (!title.trim()) return;
const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
ev.stopPropagation();
const parsed = splitTemplateTitle(template.title);
setTemplateName(parsed.name);
setVersion(parsed.version);
setSubject(template.subject ?? '');
setDialog({ mode: 'edit', template });
};
const openDuplicate = (template: ExamTemplate, ev: React.MouseEvent) => {
ev.stopPropagation();
const parsed = splitTemplateTitle(template.title);
setTemplateName(parsed.name);
setVersion(nextVersionLabel(parsed.version));
setSubject(template.subject ?? '');
setDialog({ mode: 'duplicate', template });
};
const closeDialog = () => {
if (!saving) setDialog(null);
};
const handleSaveDialog = async () => {
if (!dialog || !templateName.trim()) return;
const title = composeTemplateTitle(templateName, version);
setSaving(true);
try {
const created = await examRepository.createTemplate({
title: title.trim(),
subject: subject.trim() || undefined,
institute_id: instituteId,
source_pdf: sourcePdf,
if (dialog.mode === 'create') {
const created = await examRepository.createTemplate({
title,
subject: subject.trim() || undefined,
institute_id: instituteId,
});
setDialog(null);
navigate(`/exam-marker/${created.id}/setup`);
return;
}
if (!dialog.template) return;
if (dialog.mode === 'duplicate') {
const created = await examRepository.duplicateTemplate(dialog.template.id, title);
setTemplates((prev) => [created, ...prev]);
setDialog(null);
navigate(`/exam-marker/${created.id}/setup`);
return;
}
const updated = await examRepository.updateTemplateMeta(dialog.template.id, {
title,
subject: subject.trim() || null,
});
setCreateOpen(false);
setTitle('');
setSubject('');
setSourcePdf(null);
navigate(`/exam-marker/${created.id}/setup`);
setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
setDialog(null);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Create template failed', { message: msg });
logger.error('cc-exam-marker', 'Template action failed', { message: msg, mode: dialog.mode });
setError(msg);
} finally {
setSaving(false);
}
};
const handleArchive = async (id: string, ev: React.MouseEvent) => {
const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => {
ev.stopPropagation();
const parsed = splitTemplateTitle(template.title);
if (!window.confirm(`Archive ${parsed.name} ${parsed.version}? This hides it from the dashboard but keeps the work recoverable.`)) {
return;
}
try {
await examRepository.archiveTemplate(id);
setTemplates((prev) => prev.filter((t) => t.id !== id));
await examRepository.archiveTemplate(template.id);
setTemplates((prev) => prev.filter((t) => t.id !== template.id));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
@@ -115,6 +214,12 @@ const ExamDashboardPage: React.FC = () => {
}
};
const dialogTitle = dialog?.mode === 'edit'
? 'Rename template / edit version'
: dialog?.mode === 'duplicate'
? 'Duplicate as new version'
: 'New exam template';
return (
<Container maxWidth="lg" sx={{ py: 6 }}>
<Stack spacing={4}>
@@ -123,11 +228,11 @@ const ExamDashboardPage: React.FC = () => {
<Typography variant="h3" component="h1" gutterBottom>
Exam Marker
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
Build a template for an exam paper, then run marking batches against your classes.
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}>
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
</Typography>
</Box>
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenCreate}>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
New template
</Button>
</Box>
@@ -147,95 +252,118 @@ const ExamDashboardPage: React.FC = () => {
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Create your first template to start mapping an exam paper.
Create your first named template to start mapping an exam paper.
</Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={handleOpenCreate}>
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
New template
</Button>
</Paper>
) : (
<Grid container spacing={3}>
{templates.map((t) => (
<Grid item xs={12} sm={6} md={4} key={t.id}>
<Paper
elevation={2}
sx={{
p: 3,
height: '100%',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 1,
transition: 'box-shadow 120ms',
'&:hover': { boxShadow: 6 },
}}
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
<Tooltip title="Archive">
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
<ArchiveIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
{t.subject && (
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
)}
{t.exam_code && (
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
)}
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
<Typography variant="caption" color="text.secondary">
{new Date(t.updated_at).toLocaleDateString()}
</Typography>
</Box>
</Paper>
</Grid>
<Stack spacing={3}>
{groupedTemplates.map((group) => (
<Box key={group.key}>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="h6">{group.label}</Typography>
<Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
</Stack>
<Grid container spacing={3}>
{group.templates.map((t) => {
const parsed = splitTemplateTitle(t.title);
return (
<Grid item xs={12} sm={6} md={4} key={t.id}>
<Paper
elevation={2}
sx={{
p: 3,
height: '100%',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: 1,
transition: 'box-shadow 120ms',
'&:hover': { boxShadow: 6 },
}}
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
>
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ minWidth: 0 }}>
<Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
<Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
</Box>
<Stack direction="row" spacing={0.5}>
<Tooltip title="Rename / edit version">
<IconButton size="small" onClick={(e) => openEdit(t, e)} aria-label="rename template">
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Duplicate as new version">
<IconButton size="small" onClick={(e) => openDuplicate(t, e)} aria-label="duplicate template">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Archive">
<IconButton size="small" onClick={(e) => handleArchive(t, e)} aria-label="archive template">
<ArchiveIcon fontSize="small" />
</IconButton>
</Tooltip>
</Stack>
</Box>
{t.subject && (
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
)}
{t.exam_code && (
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
)}
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
<Typography variant="caption" color="text.secondary">
Updated {new Date(t.updated_at).toLocaleDateString()}
</Typography>
</Box>
</Paper>
</Grid>
);
})}
</Grid>
</Box>
))}
</Grid>
</Stack>
)}
</Stack>
<Dialog open={createOpen} onClose={handleCloseCreate} fullWidth maxWidth="sm">
<DialogTitle>New exam template</DialogTitle>
<Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
label="Template name"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
fullWidth
autoFocus
required
helperText="User-facing name. Several templates can share the same paper."
/>
<TextField
label="Subject"
label="Version"
value={version}
onChange={(e) => setVersion(e.target.value)}
fullWidth
helperText="Stored in the template title until the API grows a dedicated version column."
/>
<TextField
label="Paper / subject label"
value={subject}
onChange={(e) => setSubject(e.target.value)}
fullWidth
disabled={dialog?.mode === 'duplicate'}
/>
<Box>
<Button variant="outlined" component="label">
{sourcePdf ? 'Replace source PDF' : 'Attach source PDF'}
<input
hidden
type="file"
accept="application/pdf"
onChange={(e) => setSourcePdf(e.target.files?.[0] ?? null)}
/>
</Button>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{sourcePdf ? sourcePdf.name : 'Optional: upload a PDF source for this template.'}
</Typography>
</Box>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseCreate} disabled={saving}>Cancel</Button>
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
{saving ? 'Creating…' : 'Create'}
<Button onClick={closeDialog} disabled={saving}>Cancel</Button>
<Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
{saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
</Button>
</DialogActions>
</Dialog>
+1
View File
@@ -1 +1,2 @@
export { default as ExamDashboardPage } from './ExamDashboardPage';
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
@@ -0,0 +1,425 @@
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 HorizontalRuleIcon from '@mui/icons-material/HorizontalRule'
import CropSquareIcon from '@mui/icons-material/CropSquare'
import EditNoteIcon from '@mui/icons-material/EditNote'
import VisibilityIcon from '@mui/icons-material/Visibility'
import TagIcon from '@mui/icons-material/Tag'
import GradingIcon from '@mui/icons-material/Grading'
import AttachFileIcon from '@mui/icons-material/AttachFile'
import HideSourceIcon from '@mui/icons-material/HideSource'
import '@tldraw/tldraw/tldraw.css'
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
import axios from 'axios'
import { ErrorBoundary } from '../../../components/ErrorBoundary'
import { logger } from '../../../debugConfig'
import { examRepository } from '../../../services/exam/examRepository'
import type { ExamTemplateDetail } from '../../../types/exam.types'
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, pageForY, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
import { PDF_PAGE_SHAPE_TYPE, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
const TOOLS = [
{ id: 'select', label: 'Select', tip: 'Move, resize, delete, or inspect attachment pills.', color: 'inherit' as const, icon: <MouseIcon fontSize="small" /> },
{ id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Place a top line, then a bottom line; the bottom can be on a later PDF page for multi-page questions.', color: 'error' as const, icon: <HorizontalRuleIcon fontSize="small" /> },
{ id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const, icon: <CropSquareIcon fontSize="small" /> },
{ id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const, icon: <EditNoteIcon fontSize="small" /> },
{ id: SHAPE_TYPES.context, label: 'Context', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' as const, icon: <VisibilityIcon fontSize="small" /> },
{ id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number for OCR and template checking.', color: 'success' as const, icon: <TagIcon fontSize="small" /> },
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2] or Total for Question X.', color: 'success' as const, icon: <GradingIcon fontSize="small" /> },
{ id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box formula sheets, data sheets, appendices, or other student resources.', color: 'info' as const, icon: <AttachFileIcon fontSize="small" /> },
{ id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins, page numbers, blank extra space, or decoration to ignore.', color: 'inherit' as const, icon: <HideSourceIcon fontSize="small" /> },
]
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 } {
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 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 */ }
}
type BoundaryPairSummary = { index: number; label: string; startPage: number; endPage: number; multiPage: boolean }
function boundaryPairSummaries(editor: Editor | null, pages: CanvasPageGeometry[]): BoundaryPairSummary[] {
if (!editor) return []
const boundaries = editor.getCurrentPageShapes()
.map(modelFromTLShape)
.filter((shape): shape is ExamCanvasShapeModel => shape?.kind === 'boundary')
.sort((a, b) => (pageForY(a.y + a.h / 2, pages) - pageForY(b.y + b.h / 2, pages)) || (a.y - b.y))
const summaries: BoundaryPairSummary[] = []
for (let i = 0; i < boundaries.length; i += 2) {
const top = boundaries[i]
const bottom = boundaries[i + 1]
if (!top || !bottom) break
const startPage = pageForY(top.y + top.h / 2, pages)
const endPage = pageForY(bottom.y + bottom.h / 2, pages)
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${summaries.length + 1}`
summaries.push({ index: summaries.length + 1, label, startPage, endPage, multiPage: startPage !== endPage })
}
return summaries
}
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 pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dirty, setDirty] = useState(false)
const [error, setError] = useState<string | null>(null)
const [conflict, setConflict] = useState<string | null>(null)
const [activeTool, setActiveTool] = useState('select')
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
const [pdfError, setPdfError] = useState<string | null>(null)
const [boundaryPairs, setBoundaryPairs] = useState<BoundaryPairSummary[]>([])
const refreshBoundaryPairs = useCallback(() => {
setBoundaryPairs(boundaryPairSummaries(editorRef.current, pageGeometriesRef.current))
}, [])
const load = useCallback(async () => {
if (!templateId) return
setLoading(true); setError(null); setConflict(null)
try {
const detail = await examRepository.getTemplate(templateId)
setTemplate(detail)
let pages: PdfPageImage[] = []
setPdfStatus('loading')
setPdfError(null)
try {
const bytes = await examRepository.getTemplateSourcePdf(templateId)
pages = await loadPdfPageImages(bytes)
setPdfStatus(pages.length ? 'ready' : 'missing')
} catch (pdfErr) {
const pdfMsg = apiMessage(pdfErr).message
setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error')
setPdfError(pdfMsg)
logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg })
}
const geometries = pageGeometryFromImages(pages)
pageGeometriesRef.current = geometries
const editor = editorRef.current
if (editor) {
syncPdfPages(editor, pages)
loadShapes(editor, shapesFromTemplate(detail, geometries))
setBoundaryPairs(boundaryPairSummaries(editor, geometries))
} else {
setBoundaryPairs([])
}
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, refreshBoundaryPairs])
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, pageGeometriesRef.current)
const saved = await examRepository.replaceTemplate(templateId, payload)
setTemplate(saved)
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
refreshBoundaryPairs()
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, refreshBoundaryPairs])
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.icon}
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])
const canvasCss = {
'& .tlui-layout': { display: 'none' },
'& .exam-canvas-shape': {
width: '100%',
height: '100%',
boxSizing: 'border-box',
position: 'relative',
border: '2px var(--exam-shape-border-style) var(--exam-shape-stroke)',
borderRadius: 'var(--exam-shape-radius)',
background: 'var(--exam-shape-fill)',
color: 'var(--exam-shape-stroke)',
fontFamily: 'Inter, system-ui, sans-serif',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 0.75,
padding: 1,
boxShadow: '0 10px 22px rgba(15,23,42,0.10)',
overflow: 'hidden',
},
'& .exam-canvas-shape--boundary': {
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: 8,
padding: '0 64px',
background: 'transparent',
boxShadow: 'none',
},
'& .exam-canvas-shape__main-label': {
fontSize: 12,
fontWeight: 900,
textTransform: 'uppercase',
letterSpacing: 0.6,
background: 'rgba(255,255,255,0.90)',
border: '1px solid rgba(15,23,42,0.12)',
borderRadius: 999,
padding: '2px 8px',
display: 'inline-flex',
alignItems: 'center',
gap: 0.5,
lineHeight: 1.2,
},
'& .exam-canvas-shape__icon': { minWidth: 14, textAlign: 'center' },
'& .exam-canvas-shape__badge': {
fontSize: 10,
fontWeight: 800,
letterSpacing: 0.7,
textTransform: 'uppercase',
background: 'rgba(255,255,255,0.68)',
borderRadius: 999,
padding: '2px 7px',
color: 'inherit',
},
'& .exam-canvas-shape__endcap': {
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
fontSize: 11,
fontWeight: 900,
borderRadius: 999,
padding: '1px 7px',
background: 'var(--exam-shape-stroke)',
color: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.16)',
},
'& .exam-canvas-shape__endcap--left': { left: 6 },
'& .exam-canvas-shape__endcap--right': { right: 6 },
...(theme.palette.mode === 'dark' ? {
'& .exam-canvas-shape': {
borderColor: 'var(--exam-shape-stroke-dark)',
background: 'var(--exam-shape-fill-dark)',
color: 'var(--exam-shape-stroke-dark)',
boxShadow: '0 10px 22px rgba(0,0,0,0.28)',
},
'& .exam-canvas-shape--boundary': { background: 'transparent', boxShadow: 'none' },
'& .exam-canvas-shape__main-label, & .exam-canvas-shape__badge': {
background: 'rgba(15,23,42,0.84)',
borderColor: 'rgba(255,255,255,0.16)',
},
'& .exam-canvas-shape__endcap': { background: 'var(--exam-shape-stroke-dark)', color: '#0f172a' },
} : {}),
}
return (
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default' }}>
<Box sx={{ position: 'absolute', inset: 0, ...canvasCss }} data-testid="exam-template-setup-canvas">
<Tldraw
shapeUtils={examCanvasShapeUtils as any}
tools={examCanvasTools as any}
hideUi
inferDarkMode={theme.palette.mode === 'dark'}
autoFocus
onMount={(editor) => {
editorRef.current = editor
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
editor.store.listen(() => { setDirty(true); refreshBoundaryPairs() }, { scope: 'document' })
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
refreshBoundaryPairs()
}}
/>
</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: 460, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
<Typography variant="body2" color="text.secondary">
Start with boundaries: place the top line, then the bottom line. If a question continues, scroll to the later page and place the bottom boundary there. Next draw Part boxes, then Response/Context/metadata regions; Save links regions by spatial containment.
</Typography>
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap sx={{ mt: 1 }}>
<Chip size="small" color="error" variant="outlined" label="Boundary = paired red lines" />
<Chip size="small" color="warning" variant="outlined" label="Part = amber markable box" />
<Chip size="small" color="primary" variant="outlined" label="Response = blue writing area" />
<Chip size="small" color="secondary" variant="outlined" label="Context = purple stimulus" />
</Stack>
<Box sx={{ mt: 1.25, p: 1, borderRadius: 2, bgcolor: (t) => t.palette.mode === 'dark' ? 'rgba(248,113,113,0.12)' : 'rgba(239,68,68,0.06)', border: '1px dashed', borderColor: 'error.main' }}>
<Typography variant="caption" sx={{ fontWeight: 800, display: 'block' }}>Boundary pairing preview</Typography>
{boundaryPairs.length ? boundaryPairs.slice(0, 4).map((pair) => (
<Typography key={`${pair.index}-${pair.label}`} variant="caption" color={pair.multiPage ? 'warning.main' : 'text.secondary'} sx={{ display: 'block' }}>
{pair.label}: p{pair.startPage} top p{pair.endPage} bottom{pair.multiPage ? ' · multi-page span' : ''}
</Typography>
)) : (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Draw two Boundary lines to preview the saved question span.</Typography>
)}
</Box>
<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>
</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
+125
View File
@@ -0,0 +1,125 @@
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 PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
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 ExamPdfPageTLShape = TLBaseBoxShape & {
type: typeof PDF_PAGE_SHAPE_TYPE
props: { w: number; h: number; src: string; pageNumber: number }
}
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; strokeDark: string; fill: string; fillDark: string; dash?: string; label: string; icon: string; badge: string }> = {
boundary: { stroke: '#dc2626', strokeDark: '#f87171', fill: 'rgba(239,68,68,0.06)', fillDark: 'rgba(248,113,113,0.12)', dash: '12 7', label: 'Boundary', icon: '↕', badge: 'TOP / BOTTOM' },
part: { stroke: '#d97706', strokeDark: '#fbbf24', fill: 'rgba(245,158,11,0.18)', fillDark: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', badge: 'MARKABLE BOX' },
response: { stroke: '#2563eb', strokeDark: '#60a5fa', fill: 'rgba(37,99,235,0.17)', fillDark: 'rgba(96,165,250,0.32)', label: 'Response', icon: '✎', badge: 'STUDENT WRITES' },
context: { stroke: '#7c3aed', strokeDark: '#c4b5fd', fill: 'rgba(124,58,237,0.14)', fillDark: 'rgba(167,139,250,0.28)', dash: '7 5', label: 'Context', icon: '◌', badge: 'STIMULUS' },
question_number: { stroke: '#0f766e', strokeDark: '#5eead4', fill: 'rgba(15,118,110,0.15)', fillDark: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', badge: 'OCR LABEL' },
mark_area: { stroke: '#16a34a', strokeDark: '#86efac', fill: 'rgba(22,163,74,0.15)', fillDark: 'rgba(74,222,128,0.24)', label: 'Mark Area', icon: '✓', badge: 'PRINTED MARKS' },
reference: { stroke: '#0891b2', strokeDark: '#67e8f9', fill: 'rgba(8,145,178,0.14)', fillDark: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '📎', badge: 'RESOURCE' },
furniture: { stroke: '#64748b', strokeDark: '#cbd5e1', fill: 'rgba(100,116,139,0.12)', fillDark: 'rgba(148,163,184,0.22)', dash: '3 5', label: 'Furniture', icon: '×', badge: 'IGNORE' },
}
function renderShape(shape: ExamCanvasTLShape) {
const kind = shape.props.kind
const p = palette[kind] ?? palette.response
const isBoundary = kind === 'boundary'
const label = shape.props.label || p.label
return (
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
<div
className={`exam-canvas-shape exam-canvas-shape--${kind.replace('_', '-')}`}
style={{
'--exam-shape-stroke': p.stroke,
'--exam-shape-stroke-dark': p.strokeDark,
'--exam-shape-fill': p.fill,
'--exam-shape-fill-dark': p.fillDark,
'--exam-shape-border-style': p.dash ? 'dashed' : 'solid',
'--exam-shape-radius': isBoundary ? '999px' : kind === 'part' ? '6px' : '10px',
} as React.CSSProperties}
>
<span className="exam-canvas-shape__main-label">
<span className="exam-canvas-shape__icon" aria-hidden="true">{p.icon}</span>
<span>{label}</span>
</span>
{!isBoundary && <span className="exam-canvas-shape__badge">{shape.props.questionId ? 'Attached' : p.badge}</span>}
{isBoundary && <span className="exam-canvas-shape__endcap exam-canvas-shape__endcap--left">{label.toLowerCase().includes('end') ? 'B' : 'T'}</span>}
{isBoundary && <span className="exam-canvas-shape__endcap exam-canvas-shape__endcap--right">{label.toLowerCase().includes('end') ? 'Bottom' : 'Top'}</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 }
}
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) }
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
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 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 = [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 function isPdfPageShape(type: string): boolean {
return type === PDF_PAGE_SHAPE_TYPE
}
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
}
+39
View File
@@ -0,0 +1,39 @@
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): Promise<PdfPageImage[]> {
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise
const pages: PdfPageImage[] = []
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 })
const canvas = document.createElement("canvas")
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")
await page.render({ canvasContext: context, viewport }).promise
pages.push({
pageNumber,
src: canvas.toDataURL("image/png"),
width: canvas.width,
height: canvas.height,
})
}
return pages
}
-170
View File
@@ -1,170 +0,0 @@
import React, { useMemo, useRef, useState } from 'react'
import {
Tldraw,
createTLStore,
createTLSchemaFromUtils,
defaultBindingUtils,
defaultShapeUtils,
Editor,
TLAnyBindingUtilConstructor,
TLAnyShapeUtilConstructor,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
} from '@tldraw/tldraw'
import { allBindingUtils } from '../../utils/tldraw/bindings'
import { allShapeUtils } from '../../utils/tldraw/shapes'
import { customAssets } from '../../utils/tldraw/assets'
import { getUiComponents, getUiOverrides } from '../../utils/tldraw/ui-overrides'
import { HEADER_HEIGHT } from '../Layout'
import { devTools } from '../../utils/tldraw/tools'
import {
activateExamMarkerTool,
EXAM_MARKER_BOX_TYPE,
ExamMarkerBoxShapeUtil,
ExamMarkerBoxTool,
placeExamMarkerSpike,
} from '../../utils/tldraw/exam-marker-spike'
const spikeShapeUtils = [...allShapeUtils, ExamMarkerBoxShapeUtil] as TLAnyShapeUtilConstructor[]
const spikeBindingUtils = allBindingUtils as TLAnyBindingUtilConstructor[]
const spikeTools = [...devTools, ExamMarkerBoxTool]
const spikeSchema = createTLSchemaFromUtils({
shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils],
bindingUtils: [...defaultBindingUtils, ...spikeBindingUtils],
})
export default function ExamMarkerSpikePage() {
const editorRef = useRef<Editor | null>(null)
const [status, setStatus] = useState('Ready to insert an image and a custom exam-marker box.')
const store = useMemo(() => {
const nextStore = createTLStore({
schema: spikeSchema,
shapeUtils: spikeShapeUtils,
bindingUtils: spikeBindingUtils,
})
;(nextStore as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable()
return nextStore
}, [])
const onMount = (editor: Editor) => {
editorRef.current = editor
}
const insertSpike = () => {
if (!editorRef.current) return
const { imageId, boxId } = placeExamMarkerSpike(editorRef.current)
const toolId = activateExamMarkerTool(editorRef.current)
setStatus(`Inserted image ${imageId} and custom box ${boxId}; active tool is ${toolId}.`)
}
const focusBox = () => {
if (!editorRef.current) return
const current = editorRef.current.getCurrentPageShapes().find((shape) => shape.type === EXAM_MARKER_BOX_TYPE)
if (!current) {
setStatus('No exam-marker box found yet.')
return
}
const bounds = editorRef.current.getShapePageBounds(current)
if (bounds) {
editorRef.current.zoomToBounds(bounds)
setStatus('Focused the camera on the exam-marker box using getShapePageBounds + zoomToBounds.')
}
}
const uiOverrides = getUiOverrides(false)
const uiComponents = getUiComponents(false)
return (
<div style={{ position: 'fixed', inset: 0, top: HEADER_HEIGHT, background: '#f8fafc' }}>
<div
style={{
position: 'absolute',
zIndex: 10,
top: 16,
left: 16,
display: 'flex',
gap: 12,
alignItems: 'center',
padding: '10px 12px',
borderRadius: 16,
background: 'rgba(15, 23, 42, 0.9)',
color: 'white',
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.22)',
maxWidth: 880,
}}
>
<div style={{ display: 'grid', gap: 4 }}>
<strong style={{ fontSize: 14 }}>Exam-marker spike</strong>
<span style={{ fontSize: 12, opacity: 0.9 }}>{status}</span>
</div>
<button
onClick={insertSpike}
style={{
marginLeft: 'auto',
padding: '8px 12px',
borderRadius: 10,
border: 'none',
background: '#f59e0b',
color: '#111827',
fontWeight: 700,
cursor: 'pointer',
}}
>
Insert image + marker box
</button>
<button
onClick={() => {
if (!editorRef.current) return
const toolId = activateExamMarkerTool(editorRef.current)
setStatus(`Activated tool ${toolId}. Drag on the canvas to create another marker box.`)
}}
style={{
padding: '8px 12px',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.2)',
background: 'transparent',
color: 'white',
fontWeight: 700,
cursor: 'pointer',
}}
>
Activate box tool
</button>
<button
onClick={focusBox}
style={{
padding: '8px 12px',
borderRadius: 10,
border: '1px solid rgba(255,255,255,0.2)',
background: 'transparent',
color: 'white',
fontWeight: 700,
cursor: 'pointer',
}}
>
Focus marker
</button>
</div>
<Tldraw
store={store}
tools={spikeTools}
shapeUtils={spikeShapeUtils}
bindingUtils={spikeBindingUtils}
components={uiComponents}
overrides={uiOverrides}
assetUrls={customAssets}
autoFocus
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
onMount={onMount}
/>
</div>
)
}
+133 -18
View File
@@ -8,11 +8,17 @@
import axios from 'axios';
import { API_BASE } from '../../config/apiConfig';
import { logger } from '../../debugConfig';
import { supabase } from '../../supabaseClient';
import type {
CreateTemplatePayload,
ExamBoundary,
ExamQuestion,
ExamResponseArea,
ExamTemplate,
ExamTemplateDetail,
TemplateReplacePayload,
UpdateTemplateMetaPayload,
} from '../../types/exam.types';
const EXAM_BASE = `${API_BASE}/api/exam`;
@@ -25,6 +31,86 @@ async function authHeaders(): Promise<Record<string, string>> {
return { Authorization: `Bearer ${session.access_token}` };
}
function newUuid(): 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 questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
return {
id: idMap?.get(q.id) ?? q.id,
parent_id: q.parent_id ? (idMap?.get(q.parent_id) ?? q.parent_id) : null,
label: q.label,
order: q.order,
max_marks: q.max_marks,
answer_type: q.answer_type,
mcq_options: q.mcq_options,
mark_scheme: q.mark_scheme ?? {},
is_container: q.is_container,
spec_ref: q.spec_ref,
bounds: q.bounds ?? null,
page: q.page ?? null,
};
}
function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : r.id,
question_id: idMap?.get(r.question_id) ?? r.question_id,
page: r.page,
bounds: r.bounds,
kind: r.kind,
response_form: r.response_form,
context_type: r.context_type ?? null,
source: r.source,
confirmed: r.confirmed,
confidence: r.confidence,
};
}
function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate = false) {
return {
id: duplicate ? newUuid() : b.id,
question_id: b.question_id ? (idMap?.get(b.question_id) ?? b.question_id) : null,
label: b.label,
page_index: b.page_index,
y: b.y,
bounds: b.bounds,
source: b.source,
confirmed: b.confirmed,
};
}
async function replaceTemplate(
templateId: string,
detail: ExamTemplateDetail,
meta?: UpdateTemplateMetaPayload,
duplicateIds = false,
): Promise<ExamTemplateDetail> {
const headers = await authHeaders();
const idMap = new Map<string, string>();
if (duplicateIds) {
detail.questions.forEach((q) => idMap.set(q.id, newUuid()));
}
const res = await axios.put<ExamTemplateDetail>(
`${EXAM_BASE}/templates/${templateId}`,
{
meta,
questions: detail.questions.map((q) => questionPayload(q, idMap)),
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
},
{ headers },
);
return res.data;
}
export const examRepository = {
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
const headers = await authHeaders();
@@ -41,30 +127,59 @@ export const examRepository = {
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> {
const headers = await authHeaders();
if (payload.source_pdf) {
const form = new FormData();
form.append('title', payload.title);
if (payload.subject) form.append('subject', payload.subject);
if (payload.exam_id) form.append('exam_id', payload.exam_id);
if (payload.exam_code) form.append('exam_code', payload.exam_code);
if (payload.source_file_id) form.append('source_file_id', payload.source_file_id);
if (payload.page_count !== undefined) form.append('page_count', String(payload.page_count));
if (payload.institute_id) form.append('institute_id', payload.institute_id);
form.append('source_pdf', payload.source_pdf);
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, form, {
headers,
});
return res.data;
}
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
return res.data;
},
async updateTemplateMeta(templateId: string, meta: UpdateTemplateMetaPayload): Promise<ExamTemplate> {
const headers = await authHeaders();
const res = await axios.patch<ExamTemplate>(`${EXAM_BASE}/templates/${templateId}`, meta, { headers });
return res.data;
},
async duplicateTemplate(templateId: string, title: string): Promise<ExamTemplateDetail> {
const detail = await this.getTemplate(templateId);
const created = await this.createTemplate({
title,
subject: detail.subject ?? undefined,
exam_id: detail.exam_id ?? undefined,
exam_code: detail.exam_code ?? undefined,
source_file_id: detail.source_file_id ?? undefined,
page_count: detail.page_count ?? undefined,
institute_id: detail.institute_id,
});
try {
return await replaceTemplate(created.id, { ...detail, id: created.id }, { title, status: 'draft' }, true);
} catch (error) {
try {
await this.archiveTemplate(created.id);
} catch (archiveError) {
logger.error('cc-exam-marker', 'Failed to archive incomplete duplicate template', {
templateId: created.id,
message: archiveError instanceof Error ? archiveError.message : String(archiveError),
});
}
throw error;
}
},
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 });
+65 -2
View File
@@ -27,11 +27,17 @@ export interface CreateTemplatePayload {
exam_id?: string;
exam_code?: string;
source_file_id?: string;
source_pdf?: File | null;
page_count?: number;
institute_id?: string;
}
export interface UpdateTemplateMetaPayload {
title?: string;
subject?: string | null;
page_count?: number | null;
status?: ExamTemplateStatus;
}
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
export interface ExamQuestion {
id: string;
@@ -45,16 +51,27 @@ export interface ExamQuestion {
mark_scheme: Record<string, unknown>;
is_container: boolean;
spec_ref: string | null;
bounds?: Record<string, number> | null;
page?: number | null;
}
export type ExamResponseAreaKind =
| 'response'
| 'context'
| 'question_number'
| 'mark_area'
| 'reference'
| 'furniture';
export interface ExamResponseArea {
id: string;
question_id: string;
template_id: string;
page: number;
bounds: Record<string, number>;
kind: 'response' | 'context';
kind: ExamResponseAreaKind;
response_form: string | null;
context_type?: string | null;
source: 'manual' | 'ai';
confirmed: boolean;
confidence: number | null;
@@ -77,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;
}>;
}
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import type { ExamTemplateDetail } from '../../types/exam.types'
import { isUuid, pageForY, 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('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', () => {
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 })
})
})
+150
View File
@@ -0,0 +1,150 @@
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
export const PAGE_HEIGHT = 1100
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 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, 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)
}
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 {
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[], pages?: CanvasPageGeometry[]): TemplateReplacePayload {
const orderedBoundaries = shapes
.filter((s) => s.kind === 'boundary')
.sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (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: pageForShape(b, pages) - 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: pageForShape(part, pages) })
})
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) => pageForShape(part, pages) === pageForShape(region, pages)) ?? 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: 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 }
}
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): 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: pageTop(r.page, pages) + 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 ?? 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
}
@@ -1,91 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
Editor,
createTLStore,
createTLSchemaFromUtils,
defaultBindingUtils,
defaultShapeUtils,
} from '@tldraw/tldraw'
import {
EXAM_MARKER_BOX_TYPE,
ExamMarkerBoxShapeUtil,
ExamMarkerBoxTool,
activateExamMarkerTool,
buildExamMarkerSvgDataUrl,
getExamMarkerHitTestPoints,
insertExamMarkerBox,
} from './exam-marker-spike'
const spikeShapeUtils = [ExamMarkerBoxShapeUtil]
const spikeSchema = createTLSchemaFromUtils({
shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils],
bindingUtils: defaultBindingUtils,
})
if (typeof globalThis.matchMedia !== 'function') {
vi.stubGlobal('matchMedia', () => ({
matches: false,
media: '',
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
} as unknown as MediaQueryList))
}
function makeEditor() {
const store = createTLStore({
schema: spikeSchema,
shapeUtils: spikeShapeUtils,
bindingUtils: defaultBindingUtils,
})
;(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable()
return new Editor({
shapeUtils: spikeShapeUtils,
bindingUtils: defaultBindingUtils,
tools: [ExamMarkerBoxTool],
store,
getContainer: () => document.body,
})
}
describe('exam-marker spike tldraw helpers', () => {
let editor: Editor
beforeEach(() => {
editor = makeEditor()
})
afterEach(() => {
editor.dispose()
})
it('creates a custom box shape and supports point hit-testing + page bounds', () => {
const boxId = insertExamMarkerBox(editor)
const box = editor.getShape(boxId)
expect(box?.type).toBe(EXAM_MARKER_BOX_TYPE)
expect(editor.getShapePageBounds(boxId)).toBeTruthy()
const boxPoint = getExamMarkerHitTestPoints(editor, boxId)
expect(boxPoint?.bounds).toBeTruthy()
expect(editor.getShapesAtPoint(boxPoint!.center).some((shape) => shape.id === boxId)).toBe(true)
const viewport = editor.getViewportPageBounds()
expect(viewport.w).toBeGreaterThan(0)
expect(viewport.h).toBeGreaterThan(0)
expect(() => editor.zoomToBounds(boxPoint!.bounds)).not.toThrow()
})
it('registers the custom BaseBoxShapeTool and emits a locked svg asset URL', () => {
expect(activateExamMarkerTool(editor)).toBe(EXAM_MARKER_BOX_TYPE)
expect(editor.getCurrentToolId()).toBe(EXAM_MARKER_BOX_TYPE)
const svgUrl = buildExamMarkerSvgDataUrl()
expect(svgUrl.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true)
expect(decodeURIComponent(svgUrl.split(',')[1])).toContain('Exam scan')
})
})
-197
View File
@@ -1,197 +0,0 @@
import React from 'react'
import {
BaseBoxShapeTool,
BaseBoxShapeUtil,
Editor,
HTMLContainer,
T,
TLBaseBoxShape,
TLShapeId,
createShapeId,
toDomPrecision,
} from '@tldraw/tldraw'
export const EXAM_MARKER_BOX_TYPE = 'exam-marker-box' as const
export type ExamMarkerBoxShape = TLBaseBoxShape & {
type: typeof EXAM_MARKER_BOX_TYPE
}
export class ExamMarkerBoxShapeUtil extends BaseBoxShapeUtil<ExamMarkerBoxShape> {
static override type = EXAM_MARKER_BOX_TYPE
static override props = {
w: T.number,
h: T.number,
}
override getDefaultProps(): ExamMarkerBoxShape['props'] {
return {
w: 320,
h: 180,
}
}
override component(shape: ExamMarkerBoxShape) {
return (
<HTMLContainer
id={shape.id}
style={{
width: toDomPrecision(shape.props.w),
height: toDomPrecision(shape.props.h),
border: '2px solid #b91c1c',
borderRadius: 14,
background: 'linear-gradient(180deg, rgba(255, 251, 235, 0.96), rgba(254, 242, 242, 0.96))',
boxShadow: '0 12px 24px rgba(185, 28, 28, 0.18)',
boxSizing: 'border-box',
overflow: 'hidden',
pointerEvents: 'all',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
padding: 12,
fontFamily: 'Inter, system-ui, sans-serif',
color: '#7f1d1d',
height: '100%',
}}
>
<div style={{ fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8 }}>
Exam marker spike
</div>
<div style={{ fontSize: 14, lineHeight: 1.35 }}>
This custom box shape uses the app&apos;s tldraw schema + BaseBoxShapeTool path.
</div>
<div
style={{
marginTop: 'auto',
padding: '6px 8px',
borderRadius: 999,
alignSelf: 'flex-start',
background: 'rgba(185, 28, 28, 0.12)',
fontSize: 12,
fontWeight: 600,
}}
>
Drag to resize · use the spike tool to create more
</div>
</div>
</HTMLContainer>
)
}
}
export class ExamMarkerBoxTool extends BaseBoxShapeTool {
static override id = EXAM_MARKER_BOX_TYPE
static override initial = 'pointing'
shapeType = EXAM_MARKER_BOX_TYPE
}
export function buildExamMarkerSvgDataUrl(label = 'Exam scan') {
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="720" viewBox="0 0 960 720">
<defs>
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stop-color="#ffffff"/>
<stop offset="100%" stop-color="#f4f7fb"/>
</linearGradient>
</defs>
<rect width="960" height="720" rx="32" fill="url(#g)" stroke="#cbd5e1" stroke-width="6"/>
<rect x="72" y="56" width="816" height="88" rx="18" fill="#1f2937" opacity="0.92"/>
<text x="116" y="108" fill="#fff" font-size="42" font-family="Inter, Arial, sans-serif" font-weight="700">${label}</text>
<rect x="72" y="176" width="816" height="120" rx="16" fill="#eef2ff" stroke="#c7d2fe"/>
<text x="106" y="224" fill="#1e3a8a" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Question 1</text>
<text x="106" y="266" fill="#334155" font-size="24" font-family="Inter, Arial, sans-serif">Show your working and annotate the image.</text>
<rect x="72" y="332" width="816" height="250" rx="16" fill="#fff7ed" stroke="#fed7aa"/>
<text x="106" y="384" fill="#9a3412" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Answer space</text>
<rect x="106" y="420" width="700" height="18" rx="9" fill="#fdba74" opacity="0.75"/>
<rect x="106" y="470" width="640" height="18" rx="9" fill="#fdba74" opacity="0.55"/>
<rect x="106" y="520" width="570" height="18" rx="9" fill="#fdba74" opacity="0.4"/>
<text x="106" y="644" fill="#475569" font-size="20" font-family="Inter, Arial, sans-serif">Inserted as a built-in tldraw image shape.</text>
</svg>
`.trim()
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}
function getViewportAnchor(editor: Editor) {
const bounds = editor.getViewportPageBounds()
return {
x: bounds.x + bounds.w / 2,
y: bounds.y + bounds.h / 2,
}
}
export function insertExamMarkerImage(editor: Editor) {
const anchor = getViewportAnchor(editor)
const imageId = createShapeId()
editor.createShape({
id: imageId,
type: 'image',
x: anchor.x - 320,
y: anchor.y - 240,
props: {
url: buildExamMarkerSvgDataUrl(),
w: 640,
h: 480,
name: 'exam-marker-sample.svg',
},
})
return imageId
}
export function insertExamMarkerBox(editor: Editor) {
const anchor = getViewportAnchor(editor)
const boxId = createShapeId()
editor.createShape<ExamMarkerBoxShape>({
id: boxId,
type: EXAM_MARKER_BOX_TYPE,
x: anchor.x + 180,
y: anchor.y - 120,
props: {
w: 320,
h: 180,
},
})
return boxId
}
export function placeExamMarkerSpike(editor: Editor) {
const imageId = insertExamMarkerImage(editor)
const boxId = insertExamMarkerBox(editor)
const box = editor.getShape(boxId)
if (box) {
const bounds = editor.getShapePageBounds(box)
if (bounds) editor.zoomToBounds(bounds)
}
return { imageId, boxId }
}
export function activateExamMarkerTool(editor: Editor) {
editor.setCurrentTool(EXAM_MARKER_BOX_TYPE)
return editor.getCurrentToolId()
}
export function getExamMarkerHitTestPoints(editor: Editor, shapeId: TLShapeId) {
const shape = editor.getShape(shapeId)
if (!shape) return null
const bounds = editor.getShapePageBounds(shape)
if (!bounds) return null
return {
center: {
x: bounds.x + bounds.w / 2,
y: bounds.y + bounds.h / 2,
},
bounds,
}
}