Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdbc19cf0d |
@@ -35,7 +35,6 @@ vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</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> }));
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -187,7 +186,6 @@ const AppRoutes: React.FC = () => {
|
|||||||
<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 />} />
|
||||||
|
|||||||
@@ -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,10 +90,10 @@ 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 [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,47 +114,99 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
void load();
|
void load();
|
||||||
}, [load, instituteId]);
|
}, [load, instituteId]);
|
||||||
|
|
||||||
const handleOpenCreate = () => {
|
const groupedTemplates = useMemo(() => {
|
||||||
setCreateOpen(true);
|
const groups = new Map<string, { label: string; templates: ExamTemplate[] }>();
|
||||||
};
|
templates.forEach((template) => {
|
||||||
|
const key = paperKey(template);
|
||||||
|
const existing = groups.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.templates.push(template);
|
||||||
|
} else {
|
||||||
|
groups.set(key, { label: paperLabel(template), templates: [template] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(groups.entries()).map(([key, group]) => ({ key, ...group }));
|
||||||
|
}, [templates]);
|
||||||
|
|
||||||
const handleCloseCreate = () => {
|
const openCreate = () => {
|
||||||
if (saving) return;
|
setTemplateName('New template');
|
||||||
setCreateOpen(false);
|
setVersion('v1');
|
||||||
setTitle('');
|
|
||||||
setSubject('');
|
setSubject('');
|
||||||
setSourcePdf(null);
|
setDialog({ mode: 'create' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const openEdit = (template: ExamTemplate, ev: React.MouseEvent) => {
|
||||||
if (!title.trim()) return;
|
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,
|
||||||
source_pdf: sourcePdf,
|
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('');
|
|
||||||
setSourcePdf(null);
|
|
||||||
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 });
|
||||||
@@ -115,6 +214,12 @@ const ExamDashboardPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dialogTitle = dialog?.mode === 'edit'
|
||||||
|
? 'Rename template / edit version'
|
||||||
|
: dialog?.mode === 'duplicate'
|
||||||
|
? 'Duplicate as new version'
|
||||||
|
: 'New exam template';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="lg" sx={{ py: 6 }}>
|
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||||
<Stack spacing={4}>
|
<Stack spacing={4}>
|
||||||
@@ -123,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={handleOpenCreate}>
|
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreate}>
|
||||||
New template
|
New template
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -147,95 +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={handleOpenCreate}>
|
<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={{
|
<Chip size="small" label={`${group.templates.length} template${group.templates.length === 1 ? '' : 's'}`} variant="outlined" />
|
||||||
p: 3,
|
</Stack>
|
||||||
height: '100%',
|
<Grid container spacing={3}>
|
||||||
cursor: 'pointer',
|
{group.templates.map((t) => {
|
||||||
display: 'flex',
|
const parsed = splitTemplateTitle(t.title);
|
||||||
flexDirection: 'column',
|
return (
|
||||||
gap: 1,
|
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||||
transition: 'box-shadow 120ms',
|
<Paper
|
||||||
'&:hover': { boxShadow: 6 },
|
elevation={2}
|
||||||
}}
|
sx={{
|
||||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
p: 3,
|
||||||
>
|
height: '100%',
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
cursor: 'pointer',
|
||||||
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
|
display: 'flex',
|
||||||
<Tooltip title="Archive">
|
flexDirection: 'column',
|
||||||
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
|
gap: 1,
|
||||||
<ArchiveIcon fontSize="small" />
|
transition: 'box-shadow 120ms',
|
||||||
</IconButton>
|
'&:hover': { boxShadow: 6 },
|
||||||
</Tooltip>
|
}}
|
||||||
</Box>
|
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||||
{t.subject && (
|
>
|
||||||
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 1 }}>
|
||||||
)}
|
<Box sx={{ minWidth: 0 }}>
|
||||||
{t.exam_code && (
|
<Typography variant="h6" sx={{ pr: 1 }}>{parsed.name}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
<Chip size="small" label={parsed.version} color="info" variant="outlined" sx={{ mt: 0.5 }} />
|
||||||
)}
|
</Box>
|
||||||
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<Stack direction="row" spacing={0.5}>
|
||||||
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
|
<Tooltip title="Rename / edit version">
|
||||||
<Typography variant="caption" color="text.secondary">
|
<IconButton size="small" onClick={(e) => openEdit(t, e)} aria-label="rename template">
|
||||||
{new Date(t.updated_at).toLocaleDateString()}
|
<EditIcon fontSize="small" />
|
||||||
</Typography>
|
</IconButton>
|
||||||
</Box>
|
</Tooltip>
|
||||||
</Paper>
|
<Tooltip title="Duplicate as new version">
|
||||||
</Grid>
|
<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={handleCloseCreate} 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'}
|
||||||
/>
|
/>
|
||||||
<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={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,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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -8,11 +8,16 @@
|
|||||||
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,
|
||||||
|
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 +30,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();
|
||||||
@@ -43,28 +128,42 @@ 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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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 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 });
|
||||||
|
|||||||
+19
-2
@@ -27,11 +27,17 @@ 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 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;
|
||||||
@@ -45,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;
|
||||||
|
|||||||
@@ -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