Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ab8aab43c |
@@ -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/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>,
|
||||||
|
MarkSchemePage: () => <div>Mark Scheme editor</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> }));
|
||||||
vi.mock('./pages/tldraw/ExamMarkerSpikePage', () => ({ default: () => <div>Exam Marker Spike</div> }));
|
|
||||||
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
|
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
|
||||||
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
|
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
|
||||||
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
|
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
|
||||||
@@ -123,3 +125,22 @@ describe('/admin route authorization', () => {
|
|||||||
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
|
expect(screen.getByText('Platform Admin Page')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('exam-marker routes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockUseAuth.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the mark scheme editor route for authenticated users', () => {
|
||||||
|
mockUseAuth.mockReturnValue(authState({
|
||||||
|
user: { id: 'teacher-1', email: '[email protected]', user_type: 'email_teacher' },
|
||||||
|
user_role: 'email_teacher',
|
||||||
|
accessToken: 'token',
|
||||||
|
}));
|
||||||
|
|
||||||
|
renderAt('/exam-marker/template-123/marks');
|
||||||
|
|
||||||
|
expect(screen.getByText('Mark Scheme editor')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+2
-3
@@ -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, MarkSchemePage } 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';
|
||||||
@@ -16,7 +16,6 @@ import AdminDashboard from './pages/auth/adminPage';
|
|||||||
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
|
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
|
||||||
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
||||||
import DevPage from './pages/tldraw/devPage';
|
import DevPage from './pages/tldraw/devPage';
|
||||||
import ExamMarkerSpikePage from './pages/tldraw/ExamMarkerSpikePage';
|
|
||||||
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
||||||
import MorphicPage from './pages/morphicPage';
|
import MorphicPage from './pages/morphicPage';
|
||||||
import NotFound from './pages/user/NotFound';
|
import NotFound from './pages/user/NotFound';
|
||||||
@@ -184,10 +183,10 @@ 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/marks" element={<ErrorBoundary><MarkSchemePage /></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 />} />
|
||||||
<Route path="/exam-marker-spike" element={<ExamMarkerSpikePage />} />
|
|
||||||
<Route path="/dev" element={<DevPage />} />
|
<Route path="/dev" element={<DevPage />} />
|
||||||
<Route path="/dev/upload-test" element={<SimpleUploadTest />} />
|
<Route path="/dev/upload-test" element={<SimpleUploadTest />} />
|
||||||
<Route path="/single-player" element={<SinglePlayerPage />} />
|
<Route path="/single-player" element={<SinglePlayerPage />} />
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ 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 GradingIcon from '@mui/icons-material/Grading';
|
||||||
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { examRepository } from '../../services/exam/examRepository';
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
@@ -46,7 +47,6 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [subject, setSubject] = useState('');
|
const [subject, setSubject] = useState('');
|
||||||
const [sourcePdf, setSourcePdf] = useState<File | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -67,18 +67,6 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
void load();
|
void load();
|
||||||
}, [load, instituteId]);
|
}, [load, instituteId]);
|
||||||
|
|
||||||
const handleOpenCreate = () => {
|
|
||||||
setCreateOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseCreate = () => {
|
|
||||||
if (saving) return;
|
|
||||||
setCreateOpen(false);
|
|
||||||
setTitle('');
|
|
||||||
setSubject('');
|
|
||||||
setSourcePdf(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -87,12 +75,10 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
subject: subject.trim() || undefined,
|
subject: subject.trim() || undefined,
|
||||||
institute_id: instituteId,
|
institute_id: instituteId,
|
||||||
source_pdf: sourcePdf,
|
|
||||||
});
|
});
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setTitle('');
|
setTitle('');
|
||||||
setSubject('');
|
setSubject('');
|
||||||
setSourcePdf(null);
|
|
||||||
navigate(`/exam-marker/${created.id}/setup`);
|
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);
|
||||||
@@ -127,7 +113,7 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
Build a template for an exam paper, then run marking batches against your classes.
|
Build a template for an exam paper, then run marking batches against your classes.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenCreate}>
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||||
New template
|
New template
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -149,7 +135,7 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
<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 template to start mapping an exam paper.
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={handleOpenCreate}>
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||||
New template
|
New template
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -159,16 +145,8 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||||
<Paper
|
<Paper
|
||||||
elevation={2}
|
elevation={2}
|
||||||
sx={{
|
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
|
||||||
p: 3,
|
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
|
||||||
height: '100%',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 1,
|
|
||||||
transition: 'box-shadow 120ms',
|
|
||||||
'&:hover': { boxShadow: 6 },
|
|
||||||
}}
|
|
||||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||||
>
|
>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||||
@@ -191,6 +169,16 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
{new Date(t.updated_at).toLocaleDateString()}
|
{new Date(t.updated_at).toLocaleDateString()}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1} sx={{ pt: 1 }}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={(e) => { e.stopPropagation(); navigate(`/exam-marker/${t.id}/marks`); }}
|
||||||
|
startIcon={<GradingIcon fontSize="small" />}
|
||||||
|
>
|
||||||
|
Edit marks
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
@@ -198,7 +186,7 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Dialog open={createOpen} onClose={handleCloseCreate} fullWidth maxWidth="sm">
|
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
||||||
<DialogTitle>New exam template</DialogTitle>
|
<DialogTitle>New exam template</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
@@ -216,24 +204,10 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
onChange={(e) => setSubject(e.target.value)}
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
<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>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={handleCloseCreate} disabled={saving}>Cancel</Button>
|
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
||||||
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
||||||
{saving ? 'Creating…' : 'Create'}
|
{saving ? 'Creating…' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Autocomplete,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
FormControl,
|
||||||
|
Grid,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import SaveIcon from '@mui/icons-material/Save';
|
||||||
|
import SyncIcon from '@mui/icons-material/Sync';
|
||||||
|
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type {
|
||||||
|
ExamQuestion,
|
||||||
|
ExamTemplateDetail,
|
||||||
|
MarkScheme,
|
||||||
|
MarkSchemeType,
|
||||||
|
SpecPoint,
|
||||||
|
} from '../../types/exam.types';
|
||||||
|
|
||||||
|
type Drafts = Record<string, PartDraft>;
|
||||||
|
|
||||||
|
interface PartDraft {
|
||||||
|
max_marks: string;
|
||||||
|
answer_type: 'written' | 'mcq' | 'short' | 'diagram';
|
||||||
|
spec_ref: string;
|
||||||
|
schemeType: MarkSchemeType;
|
||||||
|
body: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANSWER_TYPES: Array<PartDraft['answer_type']> = ['written', 'short', 'mcq', 'diagram'];
|
||||||
|
const SCHEME_TYPES: Array<{ value: MarkSchemeType; label: string; helper: string }> = [
|
||||||
|
{ value: 'points', label: 'Points', helper: 'One mark point per line, e.g. "1 | Mentions resultant force".' },
|
||||||
|
{ value: 'levels', label: 'Levels', helper: 'One level per line, e.g. "L2 | 3-4 | Clear linked explanation".' },
|
||||||
|
{ value: 'parts', label: 'Parts', helper: 'One sub-part per line, e.g. "a | 2 | Correct substitution".' },
|
||||||
|
{ value: 'checklist', label: 'Checklist', helper: 'One checklist item per line, e.g. "1 | Correct unit".' },
|
||||||
|
{ value: 'free', label: 'Free text', helper: 'Paste or type the mark scheme exactly as it should be stored.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function deriveSpecCode(template: ExamTemplateDetail | null): string {
|
||||||
|
const examCode = template?.exam_code ?? '';
|
||||||
|
const match = examCode.match(/^([A-Z]+-[A-Z]+-\d+)/i);
|
||||||
|
return match?.[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemeToDraft(scheme: MarkScheme | null | undefined): Pick<PartDraft, 'schemeType' | 'body' | 'notes'> {
|
||||||
|
const type = (scheme?.type as MarkSchemeType | undefined) ?? 'points';
|
||||||
|
const notes = typeof scheme?.notes === 'string' ? scheme.notes : '';
|
||||||
|
|
||||||
|
if (type === 'levels' && Array.isArray(scheme?.levels)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'levels',
|
||||||
|
body: scheme.levels.map((l) => `${l.level ?? ''} | ${l.min ?? 0}-${l.max ?? 0} | ${l.descriptor ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'parts' && Array.isArray(scheme?.parts)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'parts',
|
||||||
|
body: scheme.parts.map((p) => `${p.label ?? ''} | ${p.marks ?? 0} | ${p.guidance ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'checklist' && Array.isArray(scheme?.checklist)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'checklist',
|
||||||
|
body: scheme.checklist.map((item) => `${item.marks ?? 1} | ${item.text ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'free') {
|
||||||
|
return { schemeType: 'free', body: typeof scheme?.text === 'string' ? scheme.text : '', notes };
|
||||||
|
}
|
||||||
|
if (Array.isArray(scheme?.points)) {
|
||||||
|
return {
|
||||||
|
schemeType: 'points',
|
||||||
|
body: scheme.points.map((p) => `${p.mark ?? 1} | ${p.text ?? ''}`).join('\n'),
|
||||||
|
notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { schemeType: type, body: '', notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string, fallback = 0): number {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitLine(line: string): string[] {
|
||||||
|
return line.split('|').map((part) => part.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function draftToScheme(draft: PartDraft): MarkScheme {
|
||||||
|
const lines = draft.body.split('\n').map((line) => line.trim()).filter(Boolean);
|
||||||
|
if (draft.schemeType === 'free') return { type: 'free', text: draft.body, notes: draft.notes || undefined };
|
||||||
|
if (draft.schemeType === 'levels') {
|
||||||
|
return {
|
||||||
|
type: 'levels',
|
||||||
|
levels: lines.map((line, idx) => {
|
||||||
|
const [level, range, descriptor] = splitLine(line);
|
||||||
|
const [minRaw, maxRaw] = (range || '').split('-').map((part) => part.trim());
|
||||||
|
return {
|
||||||
|
level: level || `L${idx + 1}`,
|
||||||
|
min: parseNumber(minRaw, 0),
|
||||||
|
max: parseNumber(maxRaw, parseNumber(minRaw, 0)),
|
||||||
|
descriptor: descriptor || line,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (draft.schemeType === 'parts') {
|
||||||
|
return {
|
||||||
|
type: 'parts',
|
||||||
|
parts: lines.map((line, idx) => {
|
||||||
|
const [label, marks, guidance] = splitLine(line);
|
||||||
|
return { label: label || String(idx + 1), marks: parseNumber(marks, 1), guidance: guidance || line };
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (draft.schemeType === 'checklist') {
|
||||||
|
return {
|
||||||
|
type: 'checklist',
|
||||||
|
checklist: lines.map((line) => {
|
||||||
|
const [marks, text] = splitLine(line);
|
||||||
|
return { marks: parseNumber(marks, 1), text: text || line };
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'points',
|
||||||
|
points: lines.map((line) => {
|
||||||
|
const [mark, text] = splitLine(line);
|
||||||
|
return { mark: parseNumber(mark, 1), text: text || line };
|
||||||
|
}),
|
||||||
|
notes: draft.notes || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDraft(question: ExamQuestion): PartDraft {
|
||||||
|
const scheme = schemeToDraft(question.mark_scheme);
|
||||||
|
return {
|
||||||
|
max_marks: String(question.max_marks ?? 0),
|
||||||
|
answer_type: (question.answer_type as PartDraft['answer_type'] | null) ?? 'written',
|
||||||
|
spec_ref: question.spec_ref ?? '',
|
||||||
|
schemeType: scheme.schemeType,
|
||||||
|
body: scheme.body,
|
||||||
|
notes: scheme.notes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MarkSchemePage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { templateId } = useParams<{ templateId: string }>();
|
||||||
|
|
||||||
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [drafts, setDrafts] = useState<Drafts>({});
|
||||||
|
const [specCode, setSpecCode] = useState('');
|
||||||
|
const [specPoints, setSpecPoints] = useState<SpecPoint[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [specEndpointMissing, setSpecEndpointMissing] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!templateId) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const detail = await examRepository.getTemplate(templateId);
|
||||||
|
const leafParts = detail.questions.filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label));
|
||||||
|
setTemplate(detail);
|
||||||
|
setSelectedId((prev) => prev ?? leafParts[0]?.id ?? null);
|
||||||
|
setDrafts(Object.fromEntries(leafParts.map((q) => [q.id, makeDraft(q)])));
|
||||||
|
setSpecCode((prev) => prev || deriveSpecCode(detail));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Failed to load mark scheme template', { message: msg, templateId });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const loadSpecPoints = useCallback(async () => {
|
||||||
|
if (!specCode.trim()) return;
|
||||||
|
setSpecEndpointMissing(false);
|
||||||
|
try {
|
||||||
|
setSpecPoints(await examRepository.listSpecPoints(specCode.trim()));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.warn('cc-exam-marker', 'SpecPoint endpoint unavailable; falling back to manual spec_ref entry', { message: msg, specCode });
|
||||||
|
setSpecEndpointMissing(true);
|
||||||
|
setSpecPoints([]);
|
||||||
|
}
|
||||||
|
}, [specCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSpecPoints();
|
||||||
|
}, [loadSpecPoints]);
|
||||||
|
|
||||||
|
const parts = useMemo(
|
||||||
|
() => (template?.questions ?? []).filter((q) => !q.is_container).sort((a, b) => a.order - b.order || a.label.localeCompare(b.label)),
|
||||||
|
[template],
|
||||||
|
);
|
||||||
|
const selected = parts.find((p) => p.id === selectedId) ?? null;
|
||||||
|
const draft = selected ? drafts[selected.id] : null;
|
||||||
|
const selectedSpecPoint = draft?.spec_ref ? specPoints.find((p) => p.ref === draft.spec_ref) ?? null : null;
|
||||||
|
|
||||||
|
const updateDraft = (questionId: string, patch: Partial<PartDraft>) => {
|
||||||
|
setDrafts((prev) => ({ ...prev, [questionId]: { ...prev[questionId], ...patch } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePart = async (question: ExamQuestion) => {
|
||||||
|
const partDraft = drafts[question.id];
|
||||||
|
if (!partDraft) return;
|
||||||
|
setSavingId(question.id);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
await examRepository.patchQuestion(question.id, {
|
||||||
|
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||||
|
answer_type: partDraft.answer_type,
|
||||||
|
mark_scheme: draftToScheme(partDraft),
|
||||||
|
spec_ref: partDraft.spec_ref.trim() || null,
|
||||||
|
});
|
||||||
|
setTemplate((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
questions: prev.questions.map((q) => q.id === question.id ? {
|
||||||
|
...q,
|
||||||
|
max_marks: parseNumber(partDraft.max_marks, question.max_marks),
|
||||||
|
answer_type: partDraft.answer_type,
|
||||||
|
mark_scheme: draftToScheme(partDraft),
|
||||||
|
spec_ref: partDraft.spec_ref.trim() || null,
|
||||||
|
} : q),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setNotice(`Saved mark scheme for Part ${question.label}.`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Save mark scheme failed', { message: msg, questionId: question.id });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncGraph = async () => {
|
||||||
|
if (!templateId) return;
|
||||||
|
setSyncing(true);
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
try {
|
||||||
|
const res = await examRepository.syncTemplateToGraph(templateId);
|
||||||
|
setNotice(`Neo4j projection ${res.status}; ${JSON.stringify(res.projection ?? {})}`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Neo4j sync failed', { message: msg, templateId });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
||||||
|
Back to templates
|
||||||
|
</Button>
|
||||||
|
<Typography variant="h3" component="h1">Mark Scheme editor</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary">
|
||||||
|
{template?.title ?? 'Template'} · Edit per-Part mark schemes and link assessed specification points.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
{templateId && <Button variant="outlined" onClick={() => navigate(`/exam-marker/${templateId}/setup`)}>Setup canvas</Button>}
|
||||||
|
<Button variant="outlined" startIcon={<SyncIcon />} onClick={syncGraph} disabled={syncing || !templateId}>
|
||||||
|
{syncing ? 'Syncing…' : 'Sync to graph'}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && <Alert severity="error" onClose={() => setError(null)}>{error}</Alert>}
|
||||||
|
{notice && <Alert severity="success" onClose={() => setNotice(null)}>{notice}</Alert>}
|
||||||
|
{specEndpointMissing && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
SpecPoint picker endpoint was not available from this API build. You can still type a spec_ref manually; the backend projection resolves it during neo4j-sync.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
|
<Grid container spacing={2} alignItems="center">
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<TextField
|
||||||
|
label="Specification code"
|
||||||
|
value={specCode}
|
||||||
|
onChange={(e) => setSpecCode(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
helperText="Example: AQA-PHYS-8463. Used by the SpecPoint picker."
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} md={8}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Save writes PATCH /api/exam/questions/:id. Spec refs are persisted on the Part and projected as (:Part)-[:ASSESSES]->(:SpecPoint).
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{parts.length === 0 ? (
|
||||||
|
<Alert severity="info">
|
||||||
|
This template has no Parts yet. Draw Part boxes on the setup canvas first, then return here to enter mark schemes.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={3}>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>Parts</Typography>
|
||||||
|
<Tabs
|
||||||
|
orientation="vertical"
|
||||||
|
value={selectedId ?? false}
|
||||||
|
onChange={(_, value) => setSelectedId(value)}
|
||||||
|
variant="scrollable"
|
||||||
|
sx={{ borderRight: 1, borderColor: 'divider', maxHeight: '65vh' }}
|
||||||
|
>
|
||||||
|
{parts.map((part) => (
|
||||||
|
<Tab
|
||||||
|
key={part.id}
|
||||||
|
value={part.id}
|
||||||
|
label={
|
||||||
|
<Box sx={{ textAlign: 'left', width: '100%' }}>
|
||||||
|
<Typography variant="body2">Part {part.label}</Typography>
|
||||||
|
<Stack direction="row" spacing={0.5} sx={{ mt: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
<Chip size="small" label={`${drafts[part.id]?.max_marks ?? part.max_marks} marks`} />
|
||||||
|
{(drafts[part.id]?.spec_ref || part.spec_ref) && <Chip size="small" color="info" label={drafts[part.id]?.spec_ref || part.spec_ref || ''} />}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={9}>
|
||||||
|
{selected && draft && (
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={3}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h5">Part {selected.label}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{selected.parent_id ? `Parent question ${selected.parent_id}` : 'No parent question'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<SaveIcon />}
|
||||||
|
disabled={savingId === selected.id}
|
||||||
|
onClick={() => savePart(selected)}
|
||||||
|
>
|
||||||
|
{savingId === selected.id ? 'Saving…' : 'Save part'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<TextField
|
||||||
|
label="Max marks"
|
||||||
|
value={draft.max_marks}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { max_marks: e.target.value })}
|
||||||
|
type="number"
|
||||||
|
inputProps={{ min: 0, step: 0.5 }}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="answer-type-label">Answer type</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="answer-type-label"
|
||||||
|
label="Answer type"
|
||||||
|
value={draft.answer_type}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { answer_type: e.target.value as PartDraft['answer_type'] })}
|
||||||
|
>
|
||||||
|
{ANSWER_TYPES.map((type) => <MenuItem key={type} value={type}>{type}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel id="scheme-type-label">Scheme form</InputLabel>
|
||||||
|
<Select
|
||||||
|
labelId="scheme-type-label"
|
||||||
|
label="Scheme form"
|
||||||
|
value={draft.schemeType}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { schemeType: e.target.value as MarkSchemeType })}
|
||||||
|
>
|
||||||
|
{SCHEME_TYPES.map((type) => <MenuItem key={type.value} value={type.value}>{type.label}</MenuItem>)}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
options={specPoints}
|
||||||
|
value={selectedSpecPoint}
|
||||||
|
onChange={(_, value) => updateDraft(selected.id, { spec_ref: value?.ref ?? '' })}
|
||||||
|
getOptionLabel={(option) => `${option.ref} — ${option.description}`}
|
||||||
|
isOptionEqualToValue={(option, value) => option.ref === value.ref}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="SpecPoint picker"
|
||||||
|
helperText={specPoints.length ? 'Choose a seeded SpecPoint, or type below to override.' : 'No picker results loaded; type spec_ref manually below.'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="spec_ref"
|
||||||
|
value={draft.spec_ref}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { spec_ref: e.target.value })}
|
||||||
|
helperText="Persisted to exam_questions.spec_ref; graph sync creates the ASSESSES edge when the ref matches a seeded SpecPoint."
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={`${SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.label ?? 'Mark scheme'} body`}
|
||||||
|
value={draft.body}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { body: e.target.value })}
|
||||||
|
helperText={SCHEME_TYPES.find((type) => type.value === draft.schemeType)?.helper}
|
||||||
|
multiline
|
||||||
|
minRows={10}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Internal marking notes"
|
||||||
|
value={draft.notes}
|
||||||
|
onChange={(e) => updateDraft(selected.id, { notes: e.target.value })}
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MarkSchemePage;
|
||||||
@@ -1 +1,2 @@
|
|||||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||||
|
export { default as MarkSchemePage } from './MarkSchemePage';
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,9 @@ import type {
|
|||||||
CreateTemplatePayload,
|
CreateTemplatePayload,
|
||||||
ExamTemplate,
|
ExamTemplate,
|
||||||
ExamTemplateDetail,
|
ExamTemplateDetail,
|
||||||
|
Neo4jSyncResult,
|
||||||
|
PatchQuestionPayload,
|
||||||
|
SpecPoint,
|
||||||
} from '../../types/exam.types';
|
} from '../../types/exam.types';
|
||||||
|
|
||||||
const EXAM_BASE = `${API_BASE}/api/exam`;
|
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||||
@@ -43,24 +46,6 @@ export const examRepository = {
|
|||||||
|
|
||||||
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||||
const headers = await authHeaders();
|
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 });
|
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
@@ -69,6 +54,28 @@ export const examRepository = {
|
|||||||
const headers = await authHeaders();
|
const headers = await authHeaders();
|
||||||
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async patchQuestion(questionId: string, payload: PatchQuestionPayload) {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.patch(`${EXAM_BASE}/questions/${questionId}`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listSpecPoints(specCode: string, search?: string): Promise<SpecPoint[]> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<{ points?: SpecPoint[] } | SpecPoint[]>(
|
||||||
|
`${EXAM_BASE}/specs/${encodeURIComponent(specCode)}/points`,
|
||||||
|
{ headers, params: search ? { q: search } : undefined },
|
||||||
|
);
|
||||||
|
if (Array.isArray(res.data)) return res.data;
|
||||||
|
return res.data.points ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async syncTemplateToGraph(templateId: string): Promise<Neo4jSyncResult> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<Neo4jSyncResult>(`${EXAM_BASE}/templates/${templateId}/neo4j-sync`, {}, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default examRepository;
|
export default examRepository;
|
||||||
|
|||||||
+66
-3
@@ -27,11 +27,46 @@ export interface CreateTemplatePayload {
|
|||||||
exam_id?: string;
|
exam_id?: string;
|
||||||
exam_code?: string;
|
exam_code?: string;
|
||||||
source_file_id?: string;
|
source_file_id?: string;
|
||||||
source_pdf?: File | null;
|
|
||||||
page_count?: number;
|
page_count?: number;
|
||||||
institute_id?: string;
|
institute_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MarkSchemeType = 'points' | 'levels' | 'parts' | 'checklist' | 'free';
|
||||||
|
|
||||||
|
export interface MarkSchemePoint {
|
||||||
|
mark: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkSchemeLevel {
|
||||||
|
level: string;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
descriptor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkSchemePart {
|
||||||
|
label: string;
|
||||||
|
marks: number;
|
||||||
|
guidance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkSchemeChecklistItem {
|
||||||
|
text: string;
|
||||||
|
marks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MarkScheme {
|
||||||
|
type?: MarkSchemeType;
|
||||||
|
points?: MarkSchemePoint[];
|
||||||
|
levels?: MarkSchemeLevel[];
|
||||||
|
parts?: MarkSchemePart[];
|
||||||
|
checklist?: MarkSchemeChecklistItem[];
|
||||||
|
text?: string;
|
||||||
|
notes?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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;
|
||||||
@@ -42,9 +77,11 @@ export interface ExamQuestion {
|
|||||||
max_marks: number;
|
max_marks: number;
|
||||||
answer_type: string | null;
|
answer_type: string | null;
|
||||||
mcq_options: unknown | null;
|
mcq_options: unknown | null;
|
||||||
mark_scheme: Record<string, unknown>;
|
mark_scheme: MarkScheme;
|
||||||
is_container: boolean;
|
is_container: boolean;
|
||||||
spec_ref: string | null;
|
spec_ref: string | null;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
page?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamResponseArea {
|
export interface ExamResponseArea {
|
||||||
@@ -53,8 +90,9 @@ export interface ExamResponseArea {
|
|||||||
template_id: string;
|
template_id: string;
|
||||||
page: number;
|
page: number;
|
||||||
bounds: Record<string, number>;
|
bounds: Record<string, number>;
|
||||||
kind: 'response' | 'context';
|
kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture';
|
||||||
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;
|
||||||
@@ -77,3 +115,28 @@ export interface ExamTemplateDetail extends ExamTemplate {
|
|||||||
response_areas: ExamResponseArea[];
|
response_areas: ExamResponseArea[];
|
||||||
boundaries: ExamBoundary[];
|
boundaries: ExamBoundary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PatchQuestionPayload {
|
||||||
|
label?: string;
|
||||||
|
order?: number;
|
||||||
|
max_marks?: number;
|
||||||
|
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||||
|
mcq_options?: unknown;
|
||||||
|
mark_scheme?: MarkScheme;
|
||||||
|
is_container?: boolean;
|
||||||
|
spec_ref?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpecPoint {
|
||||||
|
uid?: string;
|
||||||
|
uuid_string?: string;
|
||||||
|
ref: string;
|
||||||
|
description: string;
|
||||||
|
spec_code: string;
|
||||||
|
exam_board_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Neo4jSyncResult {
|
||||||
|
status: string;
|
||||||
|
projection?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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'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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user