Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4a1d29298 | ||
|
|
2de3e29179 | ||
|
|
29390d30ca | ||
|
|
aa2f35e467 | ||
|
|
61a189a7a2 | ||
|
|
ab6f0b09d7 | ||
|
|
16ae3aa089 | ||
|
|
496ec2cbf9 | ||
|
|
dea3275f23 | ||
|
|
f067db3eb8 | ||
|
|
fdbc19cf0d |
@@ -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; \
|
||||||
|
|||||||
@@ -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); });
|
||||||
@@ -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> }));
|
||||||
|
|||||||
+2
-1
@@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage';
|
|||||||
import SignupPage from './pages/auth/signupPage';
|
import SignupPage from './pages/auth/signupPage';
|
||||||
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
||||||
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
||||||
import { ExamDashboardPage } from './pages/exam';
|
import { ExamDashboardPage, ExamTemplateSetupPage } from './pages/exam';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
import CalendarPage from './pages/user/calendarPage';
|
import CalendarPage from './pages/user/calendarPage';
|
||||||
import SettingsPage from './pages/user/settingsPage';
|
import SettingsPage from './pages/user/settingsPage';
|
||||||
@@ -183,6 +183,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/search" element={<SearxngPage />} />
|
<Route path="/search" element={<SearxngPage />} />
|
||||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
<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="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||||
<Route path="/morphic" element={<MorphicPage />} />
|
<Route path="/morphic" element={<MorphicPage />} />
|
||||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||||
|
|||||||
@@ -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 { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -22,6 +22,8 @@ import {
|
|||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import ArchiveIcon from '@mui/icons-material/Archive';
|
import ArchiveIcon from '@mui/icons-material/Archive';
|
||||||
import AssignmentIcon from '@mui/icons-material/Assignment';
|
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 { useAuth } from '../../contexts/AuthContext';
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
@@ -34,6 +36,51 @@ const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> =
|
|||||||
archived: 'default',
|
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 ExamDashboardPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { bootstrapData } = useAuth();
|
const { bootstrapData } = useAuth();
|
||||||
@@ -43,8 +90,9 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [dialog, setDialog] = useState<TemplateDialogState>(null);
|
||||||
const [title, setTitle] = useState('');
|
const [templateName, setTemplateName] = useState('');
|
||||||
|
const [version, setVersion] = useState('v1');
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
@@ -66,33 +114,99 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
void load();
|
void load();
|
||||||
}, [load, instituteId]);
|
}, [load, instituteId]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const groupedTemplates = useMemo(() => {
|
||||||
if (!title.trim()) return;
|
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 openCreate = () => {
|
||||||
|
setTemplateName('New template');
|
||||||
|
setVersion('v1');
|
||||||
|
setSubject('');
|
||||||
|
setDialog({ mode: 'create' });
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const created = await examRepository.createTemplate({
|
if (dialog.mode === 'create') {
|
||||||
title: title.trim(),
|
const created = await examRepository.createTemplate({
|
||||||
subject: subject.trim() || undefined,
|
title,
|
||||||
institute_id: instituteId,
|
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);
|
setTemplates((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
|
||||||
setTitle('');
|
setDialog(null);
|
||||||
setSubject('');
|
|
||||||
navigate(`/exam-marker/${created.id}/setup`);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(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);
|
setError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchive = async (id: string, ev: React.MouseEvent) => {
|
const handleArchive = async (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||||
ev.stopPropagation();
|
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 {
|
try {
|
||||||
await examRepository.archiveTemplate(id);
|
await examRepository.archiveTemplate(template.id);
|
||||||
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
setTemplates((prev) => prev.filter((t) => t.id !== template.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
||||||
@@ -100,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 (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@@ -108,11 +228,11 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
<Typography variant="h3" component="h1" gutterBottom>
|
<Typography variant="h3" component="h1" gutterBottom>
|
||||||
Exam Marker
|
Exam Marker
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
|
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 620 }}>
|
||||||
Build a template for an exam paper, then run marking batches against your classes.
|
Build multiple named templates for the same paper, version them as your setup changes, and archive drafts you no longer need.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
New template
|
New template
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -132,73 +252,118 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||||
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
<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>
|
</Typography>
|
||||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
New template
|
New template
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : (
|
) : (
|
||||||
<Grid container spacing={3}>
|
<Stack spacing={3}>
|
||||||
{templates.map((t) => (
|
{groupedTemplates.map((group) => (
|
||||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
<Box key={group.key}>
|
||||||
<Paper
|
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
|
||||||
elevation={2}
|
<Typography variant="h6">{group.label}</Typography>
|
||||||
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
|
<Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
|
||||||
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
|
</Stack>
|
||||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
<Grid container spacing={3}>
|
||||||
>
|
{group.templates.map((t) => {
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
const parsed = splitTemplateTitle(t.title);
|
||||||
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
|
return (
|
||||||
<Tooltip title="Archive">
|
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||||
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
|
<Paper
|
||||||
<ArchiveIcon fontSize="small" />
|
elevation={2}
|
||||||
</IconButton>
|
sx={{
|
||||||
</Tooltip>
|
p: 3,
|
||||||
</Box>
|
height: '100%',
|
||||||
{t.subject && (
|
cursor: 'pointer',
|
||||||
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
display: 'flex',
|
||||||
)}
|
flexDirection: 'column',
|
||||||
{t.exam_code && (
|
gap: 1,
|
||||||
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
transition: 'box-shadow 120ms',
|
||||||
)}
|
'&:hover': { boxShadow: 6 },
|
||||||
<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" />
|
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||||
<Typography variant="caption" color="text.secondary">
|
>
|
||||||
{new Date(t.updated_at).toLocaleDateString()}
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
||||||
</Typography>
|
<Box sx={{ minWidth: 0 }}>
|
||||||
</Box>
|
<Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
|
||||||
</Paper>
|
<Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
|
||||||
</Grid>
|
</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>
|
</Stack>
|
||||||
|
|
||||||
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
<Dialog open={Boolean(dialog)} onClose={closeDialog} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>New exam template</DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Title"
|
label="Template name"
|
||||||
value={title}
|
value={templateName}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTemplateName(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
|
helperText="User-facing name. Several templates can share the same paper."
|
||||||
/>
|
/>
|
||||||
<TextField
|
<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}
|
value={subject}
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
disabled={dialog?.mode === 'duplicate'}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
<Button onClick={closeDialog} disabled={saving}>Cancel</Button>
|
||||||
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
<Button variant="contained" onClick={handleSaveDialog} disabled={saving || !templateName.trim()}>
|
||||||
{saving ? 'Creating…' : 'Create'}
|
{saving ? 'Saving…' : dialog?.mode === 'duplicate' ? 'Create version' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -8,11 +8,17 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
import { API_BASE } from '../../config/apiConfig';
|
import { API_BASE } from '../../config/apiConfig';
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
import { supabase } from '../../supabaseClient';
|
import { supabase } from '../../supabaseClient';
|
||||||
import type {
|
import type {
|
||||||
CreateTemplatePayload,
|
CreateTemplatePayload,
|
||||||
|
ExamBoundary,
|
||||||
|
ExamQuestion,
|
||||||
|
ExamResponseArea,
|
||||||
ExamTemplate,
|
ExamTemplate,
|
||||||
ExamTemplateDetail,
|
ExamTemplateDetail,
|
||||||
|
TemplateReplacePayload,
|
||||||
|
UpdateTemplateMetaPayload,
|
||||||
} from '../../types/exam.types';
|
} from '../../types/exam.types';
|
||||||
|
|
||||||
const EXAM_BASE = `${API_BASE}/api/exam`;
|
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||||
@@ -25,6 +31,86 @@ async function authHeaders(): Promise<Record<string, string>> {
|
|||||||
return { Authorization: `Bearer ${session.access_token}` };
|
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 = {
|
export const examRepository = {
|
||||||
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
|
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
|
||||||
const headers = await authHeaders();
|
const headers = await authHeaders();
|
||||||
@@ -41,12 +127,59 @@ 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 });
|
||||||
return res.data;
|
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> {
|
async archiveTemplate(templateId: string): Promise<void> {
|
||||||
const headers = await authHeaders();
|
const headers = await authHeaders();
|
||||||
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||||
|
|||||||
+65
-1
@@ -31,6 +31,13 @@ export interface CreateTemplatePayload {
|
|||||||
institute_id?: string;
|
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). */
|
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||||
export interface ExamQuestion {
|
export interface ExamQuestion {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,16 +51,27 @@ export interface ExamQuestion {
|
|||||||
mark_scheme: Record<string, unknown>;
|
mark_scheme: Record<string, unknown>;
|
||||||
is_container: boolean;
|
is_container: boolean;
|
||||||
spec_ref: string | null;
|
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 {
|
export interface ExamResponseArea {
|
||||||
id: string;
|
id: string;
|
||||||
question_id: string;
|
question_id: string;
|
||||||
template_id: string;
|
template_id: string;
|
||||||
page: number;
|
page: number;
|
||||||
bounds: Record<string, number>;
|
bounds: Record<string, number>;
|
||||||
kind: 'response' | 'context';
|
kind: ExamResponseAreaKind;
|
||||||
response_form: string | null;
|
response_form: string | null;
|
||||||
|
context_type?: string | null;
|
||||||
source: 'manual' | 'ai';
|
source: 'manual' | 'ai';
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
confidence: number | null;
|
confidence: number | null;
|
||||||
@@ -76,3 +94,49 @@ export interface ExamTemplateDetail extends ExamTemplate {
|
|||||||
response_areas: ExamResponseArea[];
|
response_areas: ExamResponseArea[];
|
||||||
boundaries: ExamBoundary[];
|
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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user