Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b459a6ca | ||
|
|
adc7a2a05b | ||
|
|
29554ebdbd | ||
|
|
434643596e | ||
|
|
313ab724b8 | ||
|
|
ee7d7f7cac | ||
|
|
0dd18c4fd4 | ||
|
|
5869c741d7 | ||
|
|
5b6e461706 | ||
|
|
53adc74a1c | ||
|
|
9a7cc86a75 | ||
|
|
6cba932146 | ||
|
|
ba829be8e9 | ||
|
|
9f58917c39 | ||
|
|
a375fbe37f | ||
|
|
38ca23dc3e | ||
|
|
8fe8edb341 |
@@ -47,3 +47,6 @@ docker-compose.override.yml
|
|||||||
# Local environment variants
|
# Local environment variants
|
||||||
.env.dev
|
.env.dev
|
||||||
.env.prod
|
.env.prod
|
||||||
|
|
||||||
|
# Playwright test artifacts
|
||||||
|
test-results/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { useMemo, useEffect } from 'react';
|
import { useMemo, useEffect } from 'react';
|
||||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import { useTLDraw } from './contexts/TLDrawContext';
|
import { useTLDraw } from './contexts/TLDrawContext';
|
||||||
import { theme } from './services/themeService';
|
import { theme } from './services/themeService';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
@@ -53,6 +54,7 @@ const App = React.memo(() => {
|
|||||||
<TLDrawProvider>
|
<TLDrawProvider>
|
||||||
<DarkModeSync />
|
<DarkModeSync />
|
||||||
<ThemeProvider theme={appTheme}>
|
<ThemeProvider theme={appTheme}>
|
||||||
|
<CssBaseline />
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</TLDrawProvider>
|
</TLDrawProvider>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ 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/tldraw/CCExamMarker/CCExamMarker', () => ({ CCExamMarker: () => <div>Exam Marker</div> }));
|
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> }));
|
||||||
@@ -46,6 +46,7 @@ vi.mock('./pages/tldraw/CCDocumentIntelligence/CCDocumentIntelligence', () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock('./pages/timetable', () => ({
|
vi.mock('./pages/timetable', () => ({
|
||||||
TimetablePage: () => <div>Timetable</div>,
|
TimetablePage: () => <div>Timetable</div>,
|
||||||
|
TimetableListPage: () => <div>Timetable List</div>,
|
||||||
ClassesPage: () => <div>Classes</div>,
|
ClassesPage: () => <div>Classes</div>,
|
||||||
LessonPage: () => <div>Lesson</div>,
|
LessonPage: () => <div>Lesson</div>,
|
||||||
TaughtLessonsPage: () => <div>Taught Lessons</div>,
|
TaughtLessonsPage: () => <div>Taught Lessons</div>,
|
||||||
|
|||||||
+11
-7
@@ -7,7 +7,8 @@ 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 { CCExamMarker } from './pages/tldraw/CCExamMarker/CCExamMarker';
|
import { ExamDashboardPage, ExamTemplateSetupPage } from './pages/exam';
|
||||||
|
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';
|
||||||
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
|
import TLDrawCanvas from './pages/tldraw/TLDrawCanvas';
|
||||||
@@ -30,6 +31,7 @@ import DashboardPage from './pages/user/dashboardPage';
|
|||||||
// Timetable Module Pages
|
// Timetable Module Pages
|
||||||
import {
|
import {
|
||||||
TimetablePage,
|
TimetablePage,
|
||||||
|
TimetableListPage,
|
||||||
ClassesPage,
|
ClassesPage,
|
||||||
LessonPage,
|
LessonPage,
|
||||||
TaughtLessonsPage,
|
TaughtLessonsPage,
|
||||||
@@ -66,7 +68,7 @@ const FullContextRoutes: React.FC = () => {
|
|||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
const RequireAuth: React.FC = () => {
|
||||||
const { user, isAuthResolving } = useAuth();
|
const { user, isAuthResolving } = useAuth();
|
||||||
|
|
||||||
if (isAuthResolving) {
|
if (isAuthResolving) {
|
||||||
@@ -88,7 +90,7 @@ const RequireAuth: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|||||||
return <Navigate to="/login" replace />;
|
return <Navigate to="/login" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <Outlet />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AppRoutes: React.FC = () => {
|
const AppRoutes: React.FC = () => {
|
||||||
@@ -159,10 +161,11 @@ const AppRoutes: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Authentication only routes - only render if all contexts are initialized */}
|
{/* Authentication only routes - only render if all contexts are initialized */}
|
||||||
<RequireAuth>
|
<Route element={<RequireAuth />}>
|
||||||
<Route element={<FullContextRoutes />}>
|
<Route element={<FullContextRoutes />}>
|
||||||
{/* Timetable Module Routes */}
|
{/* Timetable Module Routes */}
|
||||||
<Route path="/timetable" element={<TimetablePage />} />
|
<Route path="/timetable" element={<TimetableListPage />} />
|
||||||
|
<Route path="/timetable/:timetableId" element={<TimetablePage />} />
|
||||||
<Route path="/classes" element={<ClassesPage />} />
|
<Route path="/classes" element={<ClassesPage />} />
|
||||||
<Route path="/my-classes" element={<MyClassesPage />} />
|
<Route path="/my-classes" element={<MyClassesPage />} />
|
||||||
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
<Route path="/classes/:classId" element={<ClassDetailPage />} />
|
||||||
@@ -179,7 +182,8 @@ const AppRoutes: React.FC = () => {
|
|||||||
{/* Existing Routes */}
|
{/* Existing Routes */}
|
||||||
<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={<CCExamMarker />} />
|
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||||
|
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
||||||
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||||
<Route path="/morphic" element={<MorphicPage />} />
|
<Route path="/morphic" element={<MorphicPage />} />
|
||||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||||
@@ -190,7 +194,7 @@ const AppRoutes: React.FC = () => {
|
|||||||
<Route path="/calendar" element={<CalendarPage />} />
|
<Route path="/calendar" element={<CalendarPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</RequireAuth>
|
</Route>
|
||||||
|
|
||||||
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
|
{/* Fallback route - authenticated users go to dashboard, unauthenticated see public 404 */}
|
||||||
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
|
<Route path="*" element={user ? <Navigate to="/dashboard" replace /> : <NotFoundPublic />} />
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Container,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import ArchiveIcon from '@mui/icons-material/Archive';
|
||||||
|
import AssignmentIcon from '@mui/icons-material/Assignment';
|
||||||
|
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { examRepository } from '../../services/exam/examRepository';
|
||||||
|
import type { ExamTemplate } from '../../types/exam.types';
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<string, 'default' | 'info' | 'success' | 'warning'> = {
|
||||||
|
draft: 'warning',
|
||||||
|
ready: 'success',
|
||||||
|
archived: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExamDashboardPage: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { bootstrapData } = useAuth();
|
||||||
|
const instituteId = bootstrapData?.active_institute?.id ?? undefined;
|
||||||
|
|
||||||
|
const [templates, setTemplates] = useState<ExamTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setTemplates(await examRepository.listTemplates());
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.warn('cc-exam-marker', 'Failed to load templates', { message: msg });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load, instituteId]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const created = await examRepository.createTemplate({
|
||||||
|
title: title.trim(),
|
||||||
|
subject: subject.trim() || undefined,
|
||||||
|
institute_id: instituteId,
|
||||||
|
});
|
||||||
|
setCreateOpen(false);
|
||||||
|
setTitle('');
|
||||||
|
setSubject('');
|
||||||
|
navigate(`/exam-marker/${created.id}/setup`);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Create template failed', { message: msg });
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async (id: string, ev: React.MouseEvent) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
try {
|
||||||
|
await examRepository.archiveTemplate(id);
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error('cc-exam-marker', 'Archive failed', { message: msg });
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 6 }}>
|
||||||
|
<Stack spacing={4}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h3" component="h1" gutterBottom>
|
||||||
|
Exam Marker
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body1" color="text.secondary" sx={{ maxWidth: 560 }}>
|
||||||
|
Build a template for an exam paper, then run marking batches against your classes.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||||
|
New template
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" onClose={() => setError(null)}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<Paper variant="outlined" sx={{ p: 6, textAlign: 'center' }}>
|
||||||
|
<AssignmentIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
|
||||||
|
<Typography variant="h6" gutterBottom>No exam templates yet</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Create your first template to start mapping an exam paper.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||||
|
New template
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||||
|
<Paper
|
||||||
|
elevation={2}
|
||||||
|
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
|
||||||
|
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
|
||||||
|
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="h6" sx={{ pr: 1 }}>{t.title}</Typography>
|
||||||
|
<Tooltip title="Archive">
|
||||||
|
<IconButton size="small" onClick={(e) => handleArchive(t.id, e)} aria-label="archive template">
|
||||||
|
<ArchiveIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
{t.subject && (
|
||||||
|
<Typography variant="body2" color="text.secondary">{t.subject}</Typography>
|
||||||
|
)}
|
||||||
|
{t.exam_code && (
|
||||||
|
<Typography variant="caption" color="text.secondary">{t.exam_code}</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ mt: 'auto', pt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Chip size="small" label={t.status} color={STATUS_COLOR[t.status] ?? 'default'} variant="outlined" />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{new Date(t.updated_at).toLocaleDateString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>New exam template</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
|
<TextField
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Subject"
|
||||||
|
value={subject}
|
||||||
|
onChange={(e) => setSubject(e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
||||||
|
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
||||||
|
{saving ? 'Creating…' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExamDashboardPage;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||||
|
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, Stack, Tooltip, Typography, useTheme } from '@mui/material'
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||||
|
import SaveIcon from '@mui/icons-material/Save'
|
||||||
|
import MouseIcon from '@mui/icons-material/Mouse'
|
||||||
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
|
||||||
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
|
import { Editor, Tldraw, createShapeId, defaultBindingUtils, TLShape } from '@tldraw/tldraw'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||||
|
import { logger } from '../../../debugConfig'
|
||||||
|
import { examRepository } from '../../../services/exam/examRepository'
|
||||||
|
import type { ExamTemplateDetail } from '../../../types/exam.types'
|
||||||
|
import { ExamCanvasShapeModel, PAGE_WIDTH, type PdfPageLayout, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||||
|
import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, PDF_PAGE_SHAPE_TYPE, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes'
|
||||||
|
import { renderPdfBackdrop, replacePdfPageShapes } from './pdfLoader'
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{ id: 'select', label: 'Select', tip: 'Move, resize, or delete user regions. The PDF backdrop is locked.', color: 'default' as const },
|
||||||
|
{ id: SHAPE_TYPES.boundary, label: 'Boundary', tip: 'Draw one horizontal line. A main question is saved from each top+bottom pair.', color: 'error' as const },
|
||||||
|
{ id: SHAPE_TYPES.part, label: 'Part', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const },
|
||||||
|
{ id: SHAPE_TYPES.response, label: 'Response', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const },
|
||||||
|
{ id: SHAPE_TYPES.context, label: 'Context', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' as const },
|
||||||
|
{ id: SHAPE_TYPES.question_number, label: 'Q Number', tip: 'Box the printed question number.', color: 'success' as const },
|
||||||
|
{ id: SHAPE_TYPES.mark_area, label: 'Mark Area', tip: 'Box printed marks such as [2].', color: 'success' as const },
|
||||||
|
{ id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const },
|
||||||
|
{ id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'default' as const },
|
||||||
|
]
|
||||||
|
|
||||||
|
function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
||||||
|
if (axios.isAxiosError(err)) {
|
||||||
|
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
||||||
|
if (err.response?.status === 409) return { conflict: true, message: detail ?? 'Template has recorded marks; structural full-replace is blocked.' }
|
||||||
|
return { conflict: false, message: detail ?? err.message }
|
||||||
|
}
|
||||||
|
return { conflict: false, message: err instanceof Error ? err.message : String(err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripShapePrefix(id: string) {
|
||||||
|
return id.startsWith('shape:') ? id.slice('shape:'.length) : id
|
||||||
|
}
|
||||||
|
|
||||||
|
function modelFromTLShape(shape: TLShape): ExamCanvasShapeModel | null {
|
||||||
|
const kind = shapeTypeToKind(shape.type)
|
||||||
|
if (!kind) return null
|
||||||
|
const s = shape as ExamCanvasTLShape
|
||||||
|
return {
|
||||||
|
id: s.props.domainId || stripShapePrefix(s.id),
|
||||||
|
kind,
|
||||||
|
x: Number(s.x ?? 0),
|
||||||
|
y: Number(s.y ?? 0),
|
||||||
|
w: Number(s.props.w ?? 1),
|
||||||
|
h: Number(s.props.h ?? 1),
|
||||||
|
label: s.props.label,
|
||||||
|
maxMarks: s.props.maxMarks,
|
||||||
|
responseForm: s.props.responseForm as ExamCanvasShapeModel['responseForm'],
|
||||||
|
contextType: s.props.contextType,
|
||||||
|
questionId: s.props.questionId ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
||||||
|
const existing = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type)).map((s) => s.id)
|
||||||
|
if (existing.length) editor.deleteShapes(existing)
|
||||||
|
if (!models.length) return
|
||||||
|
editor.createShapes(models.map((m) => ({
|
||||||
|
id: createShapeId(m.id),
|
||||||
|
type: SHAPE_TYPES[m.kind],
|
||||||
|
x: m.x,
|
||||||
|
y: m.y,
|
||||||
|
props: { w: m.w, h: m.h, label: m.label ?? m.kind, kind: m.kind, domainId: m.id, maxMarks: m.maxMarks, responseForm: m.responseForm, contextType: m.contextType, questionId: m.questionId },
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
function newDomainId() {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||||
|
return `manual-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedGuide(editor: Editor) {
|
||||||
|
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
|
||||||
|
if (current.length) return
|
||||||
|
editor.createShapes([
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 start' } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', domainId: newDomainId(), label: 'Q1 end' } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', domainId: newDomainId(), label: 'Q1(a)', maxMarks: 3 } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', domainId: newDomainId(), label: 'Response', responseForm: 'lines' } },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamTemplateSetupInner: React.FC = () => {
|
||||||
|
const { templateId } = useParams<{ templateId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const theme = useTheme()
|
||||||
|
const editorRef = useRef<Editor | null>(null)
|
||||||
|
const pageLayoutsRef = useRef<PdfPageLayout[]>([])
|
||||||
|
const hydratingRef = useRef(false)
|
||||||
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [pdfLoading, setPdfLoading] = useState(false)
|
||||||
|
const [pdfStatus, setPdfStatus] = useState<string>('PDF not loaded')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [dirty, setDirty] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [conflict, setConflict] = useState<string | null>(null)
|
||||||
|
const [activeTool, setActiveTool] = useState('select')
|
||||||
|
|
||||||
|
const loadPdf = useCallback(async (id: string, editor: Editor) => {
|
||||||
|
setPdfLoading(true)
|
||||||
|
setPdfStatus('Loading PDF…')
|
||||||
|
try {
|
||||||
|
const result = await renderPdfBackdrop(id)
|
||||||
|
hydratingRef.current = true
|
||||||
|
replacePdfPageShapes(editor, result.pages)
|
||||||
|
pageLayoutsRef.current = result.pageLayouts
|
||||||
|
setPdfStatus(`${result.pages.length} PDF page${result.pages.length === 1 ? '' : 's'} loaded`)
|
||||||
|
editor.zoomToFit()
|
||||||
|
} catch (e) {
|
||||||
|
pageLayoutsRef.current = []
|
||||||
|
const msg = apiMessage(e).message
|
||||||
|
setPdfStatus(`PDF unavailable: ${msg}`)
|
||||||
|
logger.warn('cc-exam-marker', 'Template setup PDF backdrop failed', { templateId: id, message: msg })
|
||||||
|
} finally {
|
||||||
|
hydratingRef.current = false
|
||||||
|
setPdfLoading(false)
|
||||||
|
setDirty(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!templateId) return
|
||||||
|
setLoading(true); setError(null); setConflict(null)
|
||||||
|
try {
|
||||||
|
const detail = await examRepository.getTemplate(templateId)
|
||||||
|
setTemplate(detail)
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (editor) {
|
||||||
|
hydratingRef.current = true
|
||||||
|
loadShapes(editor, shapesFromTemplate(detail))
|
||||||
|
hydratingRef.current = false
|
||||||
|
await loadPdf(templateId, editor)
|
||||||
|
}
|
||||||
|
setDirty(false)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = apiMessage(e).message
|
||||||
|
logger.warn('cc-exam-marker', 'Template setup load failed', { templateId, message: msg })
|
||||||
|
setError(msg)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [loadPdf, templateId])
|
||||||
|
|
||||||
|
useEffect(() => { void load() }, [load])
|
||||||
|
|
||||||
|
const save = useCallback(async () => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor || !templateId || !template) return
|
||||||
|
setSaving(true); setError(null); setConflict(null)
|
||||||
|
try {
|
||||||
|
const shapes = editor.getCurrentPageShapes().filter((s) => s.type !== PDF_PAGE_SHAPE_TYPE).map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
||||||
|
const payload = serializeCanvasShapes(template, shapes, pageLayoutsRef.current)
|
||||||
|
const saved = await examRepository.replaceTemplate(templateId, payload)
|
||||||
|
setTemplate(saved)
|
||||||
|
hydratingRef.current = true
|
||||||
|
loadShapes(editor, shapesFromTemplate(saved))
|
||||||
|
hydratingRef.current = false
|
||||||
|
setDirty(false)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = apiMessage(e)
|
||||||
|
if (msg.conflict) setConflict(msg.message); else setError(msg.message)
|
||||||
|
logger.warn('cc-exam-marker', 'Template setup save failed', { templateId, message: msg.message })
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}, [template, templateId])
|
||||||
|
|
||||||
|
const toolButtons = useMemo(() => TOOLS.map((tool) => (
|
||||||
|
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant={activeTool === tool.id ? 'contained' : 'outlined'}
|
||||||
|
color={tool.color}
|
||||||
|
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
const editor = editorRef.current
|
||||||
|
if (!editor) return
|
||||||
|
editor.setCurrentTool(tool.id === 'select' ? 'select' : tool.id)
|
||||||
|
setActiveTool(tool.id)
|
||||||
|
}}
|
||||||
|
sx={{ justifyContent: 'flex-start', minWidth: 126 }}
|
||||||
|
>
|
||||||
|
{tool.label}
|
||||||
|
</Button>
|
||||||
|
</Tooltip>
|
||||||
|
)), [activeTool])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: 'fixed', inset: 0, zIndex: (t) => t.zIndex.drawer + 20, bgcolor: 'background.default' }}>
|
||||||
|
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
|
||||||
|
<Tldraw
|
||||||
|
shapeUtils={examCanvasShapeUtils as any}
|
||||||
|
bindingUtils={defaultBindingUtils}
|
||||||
|
tools={examCanvasTools as any}
|
||||||
|
hideUi
|
||||||
|
inferDarkMode={theme.palette.mode === 'dark'}
|
||||||
|
autoFocus
|
||||||
|
onMount={(editor) => {
|
||||||
|
editorRef.current = editor
|
||||||
|
editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' })
|
||||||
|
editor.store.listen((entry) => {
|
||||||
|
const changedPdfOnly = Object.values(entry.changes.added).every((record: any) => record?.type === PDF_PAGE_SHAPE_TYPE)
|
||||||
|
if (!hydratingRef.current && !changedPdfOnly) setDirty(true)
|
||||||
|
}, { scope: 'document' })
|
||||||
|
if (template) {
|
||||||
|
hydratingRef.current = true
|
||||||
|
loadShapes(editor, shapesFromTemplate(template))
|
||||||
|
hydratingRef.current = false
|
||||||
|
if (templateId) void loadPdf(templateId, editor)
|
||||||
|
} else seedGuide(editor)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Paper elevation={8} sx={{ position: 'absolute', top: 12, left: 12, right: 12, px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1.5, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} size="small">Back</Button>
|
||||||
|
<Divider orientation="vertical" flexItem />
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">Exam Marker › Setup · real PDF backdrop is locked; drawn regions persist with PDF-derived page numbers.</Typography>
|
||||||
|
</Box>
|
||||||
|
<Chip size="small" icon={<PictureAsPdfIcon />} color={pdfStatus.includes('loaded') ? 'info' : pdfStatus.includes('unavailable') ? 'warning' : 'default'} label={pdfLoading ? 'Loading PDF…' : pdfStatus} />
|
||||||
|
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
||||||
|
<Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper elevation={8} sx={{ position: 'absolute', top: 92, left: 12, p: 1.25, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Stack spacing={1}>{toolButtons}</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 420, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
1) Use the visible PDF pages as the paper coordinate system. 2) Draw top/bottom Boundary lines, Part boxes, then regions over the real paper. 3) Save derives page numbers from the PDF page under each shape and reloads them at the same canvas location.
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{loading && <Box sx={{ position: 'absolute', inset: 0, display: 'grid', placeItems: 'center', bgcolor: 'rgba(15,23,42,.18)' }}><CircularProgress /></Box>}
|
||||||
|
{conflict && <Alert severity="warning" sx={{ position: 'absolute', top: 86, right: 16, maxWidth: 560 }} onClose={() => setConflict(null)}>{conflict}</Alert>}
|
||||||
|
<Snackbar open={!!error} autoHideDuration={8000} onClose={() => setError(null)}><Alert severity="error" onClose={() => setError(null)}>{error}</Alert></Snackbar>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExamTemplateSetupPage: React.FC = () => (
|
||||||
|
<ErrorBoundary fallback={<Box sx={{ p: 4 }}><Alert severity="error">Template setup canvas crashed. Reload the page and try again.</Alert></Box>}>
|
||||||
|
<ExamTemplateSetupInner />
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default ExamTemplateSetupPage
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
|
||||||
|
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
||||||
|
|
||||||
|
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||||
|
|
||||||
|
export const SHAPE_TYPES = {
|
||||||
|
boundary: 'exam-boundary',
|
||||||
|
part: 'exam-part',
|
||||||
|
response: 'exam-region-response',
|
||||||
|
context: 'exam-region-context',
|
||||||
|
question_number: 'exam-region-question-number',
|
||||||
|
mark_area: 'exam-region-mark-area',
|
||||||
|
reference: 'exam-region-reference',
|
||||||
|
furniture: 'exam-region-furniture',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PdfPageTLShape = TLBaseBoxShape<typeof PDF_PAGE_SHAPE_TYPE, {
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
src: string
|
||||||
|
pageNumber: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
export type ExamCanvasTLShape = TLBaseBoxShape<typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES], {
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
label: string
|
||||||
|
kind: ExamCanvasShapeKind
|
||||||
|
domainId: string
|
||||||
|
maxMarks?: number
|
||||||
|
responseForm?: string
|
||||||
|
contextType?: string
|
||||||
|
questionId?: string | null
|
||||||
|
}>
|
||||||
|
|
||||||
|
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: string }> = {
|
||||||
|
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' },
|
||||||
|
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' },
|
||||||
|
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' },
|
||||||
|
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' },
|
||||||
|
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' },
|
||||||
|
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' },
|
||||||
|
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' },
|
||||||
|
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDomainId() {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||||
|
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
|
||||||
|
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderShape(shape: ExamCanvasTLShape) {
|
||||||
|
const kind = shape.props.kind
|
||||||
|
const p = palette[kind] ?? palette.response
|
||||||
|
const isBoundary = kind === 'boundary'
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||||||
|
<div style={{
|
||||||
|
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid ${p.stroke}`,
|
||||||
|
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||||
|
background: isBoundary ? 'transparent' : p.fill, color: p.stroke, fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
||||||
|
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? 'none' : '0 10px 22px rgba(15,23,42,0.08)', overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.6, background: 'rgba(255,255,255,0.84)', borderRadius: 999, padding: '2px 7px' }}>
|
||||||
|
{shape.props.label || p.label}
|
||||||
|
</span>
|
||||||
|
{!isBoundary && shape.props.questionId && <span style={{ fontSize: 11, fontWeight: 700, opacity: .75 }}>Attached</span>}
|
||||||
|
</div>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||||
|
const p = palette[kind]
|
||||||
|
return { w, h, label: p.label, kind, domainId: makeDomainId(), responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
class PdfPageUtil extends BaseBoxShapeUtil<PdfPageTLShape> {
|
||||||
|
static override type = PDF_PAGE_SHAPE_TYPE
|
||||||
|
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
|
||||||
|
override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
|
||||||
|
override canEdit() { return false }
|
||||||
|
override canResize() { return false }
|
||||||
|
override hideResizeHandles() { return true }
|
||||||
|
override hideRotateHandle() { return true }
|
||||||
|
override component(shape: PdfPageTLShape) {
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
|
||||||
|
<img
|
||||||
|
alt={`PDF page ${shape.props.pageNumber}`}
|
||||||
|
src={shape.props.src}
|
||||||
|
draggable={false}
|
||||||
|
style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', boxShadow: '0 12px 26px rgba(15,23,42,0.22)', background: '#fff' }}
|
||||||
|
/>
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = { w: T.number, h: T.number, label: T.string, kind: T.string, domainId: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string) }; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
||||||
|
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
||||||
|
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = BoundaryUtil.props; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } } }
|
||||||
|
|
||||||
|
class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary }
|
||||||
|
class PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
|
||||||
|
class ResponseTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.response; static override initial = 'pointing'; shapeType = SHAPE_TYPES.response }
|
||||||
|
class ContextTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.context; static override initial = 'pointing'; shapeType = SHAPE_TYPES.context }
|
||||||
|
class QuestionNumberTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.question_number; static override initial = 'pointing'; shapeType = SHAPE_TYPES.question_number }
|
||||||
|
class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.mark_area; static override initial = 'pointing'; shapeType = SHAPE_TYPES.mark_area }
|
||||||
|
class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference }
|
||||||
|
class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture }
|
||||||
|
|
||||||
|
export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const
|
||||||
|
export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const
|
||||||
|
|
||||||
|
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
|
||||||
|
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
|
||||||
|
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { Editor } from '@tldraw/tldraw'
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist'
|
||||||
|
import pdfWorker from 'pdfjs-dist/build/pdf.worker.mjs?url'
|
||||||
|
|
||||||
|
import { examRepository } from '../../../services/exam/examRepository'
|
||||||
|
import { PAGE_GAP, PAGE_WIDTH, type PdfPageLayout } from '../../../utils/exam-canvas/model'
|
||||||
|
import { PDF_PAGE_SHAPE_TYPE, type PdfPageTLShape } from './examCanvasShapes'
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker
|
||||||
|
|
||||||
|
export interface PdfBackdropPage {
|
||||||
|
page: number
|
||||||
|
src: string
|
||||||
|
layout: PdfPageLayout
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfBackdropResult {
|
||||||
|
pages: PdfBackdropPage[]
|
||||||
|
pageLayouts: PdfPageLayout[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderPdfBackdrop(templateId: string): Promise<PdfBackdropResult> {
|
||||||
|
const bytes = await examRepository.getTemplateSourcePdf(templateId)
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(bytes) })
|
||||||
|
const pdf = await loadingTask.promise
|
||||||
|
const pages: PdfBackdropPage[] = []
|
||||||
|
let y = 0
|
||||||
|
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||||
|
const page = await pdf.getPage(pageNumber)
|
||||||
|
const nativeViewport = page.getViewport({ scale: 1 })
|
||||||
|
const layoutScale = PAGE_WIDTH / nativeViewport.width
|
||||||
|
const renderScale = layoutScale * Math.min(window.devicePixelRatio || 1, 2)
|
||||||
|
const renderViewport = page.getViewport({ scale: renderScale })
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const context = canvas.getContext('2d')
|
||||||
|
if (!context) throw new Error('Could not create 2D canvas for PDF rendering')
|
||||||
|
canvas.width = Math.ceil(renderViewport.width)
|
||||||
|
canvas.height = Math.ceil(renderViewport.height)
|
||||||
|
await page.render({ canvasContext: context, viewport: renderViewport }).promise
|
||||||
|
|
||||||
|
const layout: PdfPageLayout = {
|
||||||
|
page: pageNumber,
|
||||||
|
x: 0,
|
||||||
|
y,
|
||||||
|
w: PAGE_WIDTH,
|
||||||
|
h: nativeViewport.height * layoutScale,
|
||||||
|
}
|
||||||
|
pages.push({ page: pageNumber, src: canvas.toDataURL('image/jpeg', 0.9), layout })
|
||||||
|
y += layout.h + PAGE_GAP
|
||||||
|
}
|
||||||
|
|
||||||
|
await pdf.destroy()
|
||||||
|
return { pages, pageLayouts: pages.map((p) => p.layout) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replacePdfPageShapes(editor: Editor, pages: PdfBackdropPage[]) {
|
||||||
|
const old = editor.getCurrentPageShapes().filter((shape) => shape.type === PDF_PAGE_SHAPE_TYPE).map((shape) => shape.id)
|
||||||
|
if (old.length) editor.deleteShapes(old)
|
||||||
|
if (!pages.length) return
|
||||||
|
|
||||||
|
editor.createShapes(pages.map((page) => ({
|
||||||
|
id: `shape:pdf-page-${page.page}` as PdfPageTLShape['id'],
|
||||||
|
type: PDF_PAGE_SHAPE_TYPE,
|
||||||
|
x: page.layout.x,
|
||||||
|
y: page.layout.y,
|
||||||
|
isLocked: true,
|
||||||
|
props: {
|
||||||
|
w: page.layout.w,
|
||||||
|
h: page.layout.h,
|
||||||
|
src: page.src,
|
||||||
|
pageNumber: page.page,
|
||||||
|
},
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -3,9 +3,19 @@ import { Link } from 'react-router-dom';
|
|||||||
import { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material';
|
import { AccessTime, KeyboardArrowRight, MenuBook, People, School } from '@mui/icons-material';
|
||||||
import useTimetableStore from '../../stores/timetableStore';
|
import useTimetableStore from '../../stores/timetableStore';
|
||||||
import { useUser } from '../../contexts/UserContext';
|
import { useUser } from '../../contexts/UserContext';
|
||||||
|
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
|
||||||
|
import { CircularProgress } from '@mui/material';
|
||||||
|
import { logger } from '../../debugConfig';
|
||||||
|
|
||||||
|
const MyClassesSkeleton = () => (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const MyClassesPage: React.FC = () => {
|
const MyClassesPage: React.FC = () => {
|
||||||
const { profile } = useUser();
|
const { profile } = useUser();
|
||||||
|
const { selectedInstituteId } = useNeoInstitute();
|
||||||
const {
|
const {
|
||||||
myClasses,
|
myClasses,
|
||||||
classesLoading: myClassesLoading,
|
classesLoading: myClassesLoading,
|
||||||
@@ -14,8 +24,16 @@ const MyClassesPage: React.FC = () => {
|
|||||||
} = useTimetableStore();
|
} = useTimetableStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMyClasses();
|
fetchMyClasses().catch((error) =>
|
||||||
}, [fetchMyClasses]);
|
logger.warn('my-classes', 'Fetch my classes failed', {
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [fetchMyClasses, selectedInstituteId]);
|
||||||
|
|
||||||
|
if (myClassesLoading) {
|
||||||
|
return <MyClassesSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
const getRoleLabel = (role: string) => {
|
const getRoleLabel = (role: string) => {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
@@ -36,14 +54,6 @@ const MyClassesPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (myClassesLoading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center items-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (myClassesError) {
|
if (myClassesError) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { CalendarToday, KeyboardArrowRight } from '@mui/icons-material';
|
||||||
|
import useTimetableStore from '../../stores/timetableStore';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
const TimetableListPage: React.FC = () => {
|
||||||
|
const { timetables, timetablesLoading, timetablesError, fetchTimetables } = useTimetableStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTimetables();
|
||||||
|
}, [fetchTimetables]);
|
||||||
|
|
||||||
|
if (timetablesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat fetch errors (including 404 — endpoint not yet available) as empty state
|
||||||
|
const showEmpty = timetablesError || timetables.length === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-6 max-w-4xl">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Timetables</h1>
|
||||||
|
<p className="mt-1 text-gray-500">Your scheduled timetables</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEmpty ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<CalendarToday sx={{ fontSize: 48 }} className="mx-auto mb-4 text-gray-300" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">No timetables yet</h3>
|
||||||
|
<p className="mt-2 text-gray-500">
|
||||||
|
Timetables are created from a class. Go to{' '}
|
||||||
|
<Link to="/classes" className="text-blue-600 hover:underline">Classes</Link>{' '}
|
||||||
|
to get started.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{timetables.map((timetable) => (
|
||||||
|
<Link
|
||||||
|
key={timetable.id}
|
||||||
|
to={`/timetable/${timetable.id}`}
|
||||||
|
className="flex items-center gap-4 p-4 bg-white rounded-xl shadow-sm border border-gray-200 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<CalendarToday className="text-blue-500 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 truncate">{timetable.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{format(parseISO(timetable.start_date), 'MMM d, yyyy')}
|
||||||
|
{timetable.end_date
|
||||||
|
? ` – ${format(parseISO(timetable.end_date), 'MMM d, yyyy')}`
|
||||||
|
: ' (ongoing)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{timetable.recurrence_rule && (
|
||||||
|
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs font-medium rounded-full flex-shrink-0">
|
||||||
|
Recurring
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<KeyboardArrowRight className="text-gray-400 flex-shrink-0" />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimetableListPage;
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { default as TimetablePage } from './TimetablePage';
|
export { default as TimetablePage } from './TimetablePage';
|
||||||
|
export { default as TimetableListPage } from './TimetableListPage';
|
||||||
export { default as ClassesPage } from './ClassesPage';
|
export { default as ClassesPage } from './ClassesPage';
|
||||||
export { default as LessonPage } from './LessonPage';
|
export { default as LessonPage } from './LessonPage';
|
||||||
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
|
export { default as TaughtLessonsPage } from './TaughtLessonsPage';
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { TLShapeId } from '@tldraw/tldraw';
|
|
||||||
|
|
||||||
export interface AnnotationData {
|
|
||||||
studentIndex?: number; // undefined for exam/markscheme annotations
|
|
||||||
pageIndex: number;
|
|
||||||
shapeId: TLShapeId;
|
|
||||||
bounds: {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AnnotationManager {
|
|
||||||
private examAnnotations: Set<TLShapeId> = new Set();
|
|
||||||
private markSchemeAnnotations: Set<TLShapeId> = new Set();
|
|
||||||
private studentAnnotations: Map<number, Set<TLShapeId>> = new Map();
|
|
||||||
private annotationData: Map<TLShapeId, AnnotationData> = new Map();
|
|
||||||
|
|
||||||
addAnnotation(shapeId: TLShapeId, data: AnnotationData) {
|
|
||||||
this.annotationData.set(shapeId, data);
|
|
||||||
|
|
||||||
if (data.studentIndex !== undefined) {
|
|
||||||
// Student response annotation
|
|
||||||
let studentSet = this.studentAnnotations.get(data.studentIndex);
|
|
||||||
if (!studentSet) {
|
|
||||||
studentSet = new Set();
|
|
||||||
this.studentAnnotations.set(data.studentIndex, studentSet);
|
|
||||||
}
|
|
||||||
studentSet.add(shapeId);
|
|
||||||
} else {
|
|
||||||
// Exam or mark scheme annotation
|
|
||||||
if (data.pageIndex < 0) {
|
|
||||||
this.examAnnotations.add(shapeId);
|
|
||||||
} else {
|
|
||||||
this.markSchemeAnnotations.add(shapeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeAnnotation(shapeId: TLShapeId) {
|
|
||||||
const data = this.annotationData.get(shapeId);
|
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
if (data.studentIndex !== undefined) {
|
|
||||||
const studentSet = this.studentAnnotations.get(data.studentIndex);
|
|
||||||
studentSet?.delete(shapeId);
|
|
||||||
} else {
|
|
||||||
if (data.pageIndex < 0) {
|
|
||||||
this.examAnnotations.delete(shapeId);
|
|
||||||
} else {
|
|
||||||
this.markSchemeAnnotations.delete(shapeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.annotationData.delete(shapeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationsForStudent(studentIndex: number): TLShapeId[] {
|
|
||||||
return Array.from(this.studentAnnotations.get(studentIndex) || []);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationsForExam(): TLShapeId[] {
|
|
||||||
return Array.from(this.examAnnotations);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationsForMarkScheme(): TLShapeId[] {
|
|
||||||
return Array.from(this.markSchemeAnnotations);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAnnotationData(shapeId: TLShapeId): AnnotationData | undefined {
|
|
||||||
return this.annotationData.get(shapeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this.examAnnotations.clear();
|
|
||||||
this.markSchemeAnnotations.clear();
|
|
||||||
this.studentAnnotations.clear();
|
|
||||||
this.annotationData.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Future transcription support
|
|
||||||
addTranscriptionToAnnotation(shapeId: TLShapeId) {
|
|
||||||
const data = this.annotationData.get(shapeId);
|
|
||||||
if (data) {
|
|
||||||
this.annotationData.set(shapeId, {
|
|
||||||
...data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import 'tldraw/tldraw.css';
|
|
||||||
import { CCPdfEditor } from './CCPdfEditor';
|
|
||||||
import { CCPdfPicker } from './CCPdfPicker';
|
|
||||||
import { ExamPdfState } from './types';
|
|
||||||
import './cc-exam-marker.css';
|
|
||||||
import { HEADER_HEIGHT } from '../../Layout';
|
|
||||||
import { CCPanel } from '../../../utils/tldraw/ui-overrides/components/CCPanel';
|
|
||||||
|
|
||||||
export const CCExamMarker = () => {
|
|
||||||
const [state, setState] = useState<ExamPdfState>({ phase: 'pick' });
|
|
||||||
const [view, setView] = useState<'exam-and-markscheme' | 'student-responses'>('exam-and-markscheme');
|
|
||||||
const [currentStudentIndex, setCurrentStudentIndex] = useState(0);
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
const [isPinned, setIsPinned] = useState(false);
|
|
||||||
|
|
||||||
const handleViewChange = (newView: 'exam-and-markscheme' | 'student-responses') => {
|
|
||||||
setView(newView);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextStudent = () => {
|
|
||||||
if (state.phase === 'edit' && 'studentResponses' in state && 'examPaper' in state) {
|
|
||||||
const totalStudents = Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length);
|
|
||||||
if (currentStudentIndex < totalStudents - 1) {
|
|
||||||
setCurrentStudentIndex(prev => prev + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreviousStudent = () => {
|
|
||||||
if (currentStudentIndex > 0) {
|
|
||||||
setCurrentStudentIndex(prev => prev - 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{
|
|
||||||
position: 'fixed',
|
|
||||||
inset: 0,
|
|
||||||
top: `${HEADER_HEIGHT}px`,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
bgcolor: 'background.default',
|
|
||||||
color: 'text.primary',
|
|
||||||
}}>
|
|
||||||
{state.phase === 'pick' ? (
|
|
||||||
<CCPdfPicker
|
|
||||||
onOpenPdfs={(pdfs) =>
|
|
||||||
setState({
|
|
||||||
phase: 'edit',
|
|
||||||
examPaper: pdfs.examPaper,
|
|
||||||
markScheme: pdfs.markScheme,
|
|
||||||
studentResponses: pdfs.studentResponses,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Box sx={{ flex: 1, position: 'relative' }}>
|
|
||||||
<Box sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
bgcolor: 'background.paper',
|
|
||||||
}}>
|
|
||||||
<CCPdfEditor
|
|
||||||
examPaper={state.examPaper}
|
|
||||||
markScheme={state.markScheme}
|
|
||||||
studentResponses={state.studentResponses}
|
|
||||||
currentView={view}
|
|
||||||
currentStudentIndex={currentStudentIndex}
|
|
||||||
onEditorMount={(editor) => {
|
|
||||||
if (!editor) return null;
|
|
||||||
const examMarkerProps = {
|
|
||||||
editor,
|
|
||||||
currentView: view,
|
|
||||||
onViewChange: handleViewChange,
|
|
||||||
currentStudentIndex,
|
|
||||||
totalStudents: Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length),
|
|
||||||
onPreviousStudent: handlePreviousStudent,
|
|
||||||
onNextStudent: handleNextStudent,
|
|
||||||
getCurrentPdf: () => {
|
|
||||||
if (!editor) return null;
|
|
||||||
const currentPageId = editor.getCurrentPageId();
|
|
||||||
if (currentPageId.includes('exam-page')) {
|
|
||||||
return state.examPaper;
|
|
||||||
} else if (currentPageId.includes('mark-scheme-page')) {
|
|
||||||
return state.markScheme;
|
|
||||||
} else if (currentPageId.includes('student-response')) {
|
|
||||||
return state.studentResponses;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return <CCPanel
|
|
||||||
examMarkerProps={examMarkerProps}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
isPinned={isPinned}
|
|
||||||
onExpandedChange={setIsExpanded}
|
|
||||||
onPinnedChange={setIsPinned}
|
|
||||||
/>;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { PDFDocument } from 'pdf-lib';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { Editor, exportToBlob } from '@tldraw/tldraw';
|
|
||||||
import { Button } from '@mui/material';
|
|
||||||
import { Pdf } from './types';
|
|
||||||
|
|
||||||
interface CCExportPdfButtonProps {
|
|
||||||
editor: Editor;
|
|
||||||
pdf: Pdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
|
|
||||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const exportPdf = async (
|
|
||||||
editor: Editor,
|
|
||||||
{ name, source, pages }: Pdf,
|
|
||||||
onProgress: (progress: number) => void
|
|
||||||
) => {
|
|
||||||
const totalThings = pages.length * 2 + 2;
|
|
||||||
let progressCount = 0;
|
|
||||||
const tickProgress = () => {
|
|
||||||
progressCount++;
|
|
||||||
onProgress(progressCount / totalThings);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pdf = await PDFDocument.load(source);
|
|
||||||
tickProgress();
|
|
||||||
const pdfPages = pdf.getPages();
|
|
||||||
|
|
||||||
if (pdfPages.length !== pages.length) {
|
|
||||||
throw new Error('PDF page count mismatch');
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageShapeIds = new Set(pages.map((page) => page.shapeId));
|
|
||||||
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter(
|
|
||||||
(id) => !pageShapeIds.has(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < pages.length; i++) {
|
|
||||||
const page = pages[i];
|
|
||||||
const pdfPage = pdfPages[i];
|
|
||||||
const {bounds} = page;
|
|
||||||
|
|
||||||
const shapesInBounds = allIds.filter((id) => {
|
|
||||||
const shapePageBounds = editor.getShapePageBounds(id);
|
|
||||||
if (!shapePageBounds) return false;
|
|
||||||
return shapePageBounds.collides(bounds);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shapesInBounds.length === 0) {
|
|
||||||
tickProgress();
|
|
||||||
tickProgress();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exportedPng = await exportToBlob({
|
|
||||||
editor,
|
|
||||||
ids: allIds,
|
|
||||||
format: 'png',
|
|
||||||
opts: { background: false, bounds: page.bounds, padding: 0, scale: 1 },
|
|
||||||
});
|
|
||||||
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: pdfPage.getWidth(),
|
|
||||||
height: pdfPage.getHeight(),
|
|
||||||
});
|
|
||||||
|
|
||||||
tickProgress();
|
|
||||||
}
|
|
||||||
|
|
||||||
const pdfBytes = await pdf.save();
|
|
||||||
const url = URL.createObjectURL(
|
|
||||||
new Blob([pdfBytes.buffer as ArrayBuffer], { type: 'application/pdf' })
|
|
||||||
);
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = name;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
className="CCExportPdfButton"
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={async () => {
|
|
||||||
setExportProgress(0);
|
|
||||||
try {
|
|
||||||
await exportPdf(editor, pdf, setExportProgress);
|
|
||||||
} finally {
|
|
||||||
setExportProgress(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 16,
|
|
||||||
right: 16,
|
|
||||||
zIndex: 1000,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{exportProgress
|
|
||||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
|
||||||
: 'Export PDF'}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Box } from '@mui/material';
|
|
||||||
import { Editor, TLPageId, Box as TLBox } from '@tldraw/editor';
|
|
||||||
import { Tldraw } from '@tldraw/tldraw';
|
|
||||||
import { useCallback, useEffect, useState, useRef } from 'react';
|
|
||||||
import { ExamPdfs } from './types';
|
|
||||||
import { AnnotationManager, AnnotationData } from './AnnotationManager';
|
|
||||||
import { logger } from '../../../debugConfig';
|
|
||||||
|
|
||||||
const PAGE_SPACING = 32; // Same spacing as the example
|
|
||||||
|
|
||||||
interface CCPdfEditorProps extends ExamPdfs {
|
|
||||||
currentView: 'exam-and-markscheme' | 'student-responses';
|
|
||||||
currentStudentIndex: number;
|
|
||||||
onEditorMount: (editor: Editor) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CCPdfEditor({
|
|
||||||
examPaper,
|
|
||||||
markScheme,
|
|
||||||
studentResponses,
|
|
||||||
currentView,
|
|
||||||
currentStudentIndex,
|
|
||||||
onEditorMount,
|
|
||||||
}: CCPdfEditorProps) {
|
|
||||||
const [editor, setEditor] = useState<Editor | null>(null);
|
|
||||||
const [pagesInitialized, setPagesInitialized] = useState(false);
|
|
||||||
const annotationManager = useRef(new AnnotationManager());
|
|
||||||
|
|
||||||
const handleMount = useCallback((editor: Editor) => {
|
|
||||||
setEditor(editor);
|
|
||||||
onEditorMount(editor);
|
|
||||||
|
|
||||||
// Subscribe to shape changes
|
|
||||||
editor.on('change', () => {
|
|
||||||
const shapes = editor.getCurrentPageShapeIds();
|
|
||||||
logger.debug('cc-exam-marker', '🔄 Shape change detected', {
|
|
||||||
totalShapes: shapes.size,
|
|
||||||
currentPage: editor.getCurrentPageId()
|
|
||||||
});
|
|
||||||
|
|
||||||
shapes.forEach(shapeId => {
|
|
||||||
const shape = editor.getShape(shapeId);
|
|
||||||
if (shape && !shape.isLocked) { // Only track non-locked shapes (annotations)
|
|
||||||
const bounds = editor.getShapePageBounds(shapeId);
|
|
||||||
if (bounds) {
|
|
||||||
const currentPageId = editor.getCurrentPageId();
|
|
||||||
let annotationData: AnnotationData;
|
|
||||||
|
|
||||||
if (currentPageId.includes('student-response')) {
|
|
||||||
const studentIndex = parseInt(currentPageId.split('-').pop() || '0', 10);
|
|
||||||
|
|
||||||
// Find which page this annotation belongs to by checking collision with page bounds
|
|
||||||
const pageShapes = Array.from(shapes).filter(id => {
|
|
||||||
const s = editor.getShape(id);
|
|
||||||
return s?.isLocked; // Locked shapes are our PDF pages
|
|
||||||
});
|
|
||||||
|
|
||||||
let pageIndex = -1; // Default to -1 if no collision found
|
|
||||||
for (let i = 0; i < pageShapes.length; i++) {
|
|
||||||
const pageShape = editor.getShape(pageShapes[i]);
|
|
||||||
if (!pageShape) continue;
|
|
||||||
|
|
||||||
const pageBounds = editor.getShapePageBounds(pageShapes[i]);
|
|
||||||
if (!pageBounds) continue;
|
|
||||||
|
|
||||||
// Check if the annotation's center point is within the page bounds
|
|
||||||
const annotationCenter = {
|
|
||||||
x: bounds.x + bounds.width / 2,
|
|
||||||
y: bounds.y + bounds.height / 2
|
|
||||||
};
|
|
||||||
|
|
||||||
if (annotationCenter.x >= pageBounds.x &&
|
|
||||||
annotationCenter.x <= pageBounds.x + pageBounds.width &&
|
|
||||||
annotationCenter.y >= pageBounds.y &&
|
|
||||||
annotationCenter.y <= pageBounds.y + pageBounds.height) {
|
|
||||||
pageIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '📏 Calculated page index', {
|
|
||||||
shapeId,
|
|
||||||
shapeBounds: bounds,
|
|
||||||
pageIndex,
|
|
||||||
studentIndex
|
|
||||||
});
|
|
||||||
|
|
||||||
annotationData = {
|
|
||||||
studentIndex,
|
|
||||||
pageIndex,
|
|
||||||
shapeId,
|
|
||||||
bounds: {
|
|
||||||
x: bounds.x,
|
|
||||||
y: bounds.y,
|
|
||||||
width: bounds.width,
|
|
||||||
height: bounds.height,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// For exam/mark scheme, use current page type as index
|
|
||||||
const pageIndex = currentPageId.includes('exam') ? -1 : 1;
|
|
||||||
annotationData = {
|
|
||||||
pageIndex,
|
|
||||||
shapeId,
|
|
||||||
bounds: {
|
|
||||||
x: bounds.x,
|
|
||||||
y: bounds.y,
|
|
||||||
width: bounds.width,
|
|
||||||
height: bounds.height,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '📝 Adding/updating annotation', {
|
|
||||||
shapeId,
|
|
||||||
annotationData,
|
|
||||||
currentPage: currentPageId
|
|
||||||
});
|
|
||||||
|
|
||||||
annotationManager.current.addAnnotation(shapeId, annotationData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [onEditorMount]);
|
|
||||||
|
|
||||||
// Initial setup effect - runs only once when editor is mounted
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor || pagesInitialized) return;
|
|
||||||
|
|
||||||
const setupExamAndMarkScheme = async () => {
|
|
||||||
const examPageId = 'page:exam-page' as TLPageId;
|
|
||||||
const markSchemePageId = 'page:mark-scheme-page' as TLPageId;
|
|
||||||
|
|
||||||
// Calculate vertical layout for exam pages
|
|
||||||
let top = 0;
|
|
||||||
let widest = 0;
|
|
||||||
const examPages = examPaper.pages.map(page => {
|
|
||||||
const width = page.bounds.width;
|
|
||||||
const height = page.bounds.height;
|
|
||||||
const currentTop = top;
|
|
||||||
top += height + PAGE_SPACING;
|
|
||||||
widest = Math.max(widest, width);
|
|
||||||
return { ...page, top: currentTop, width, height };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Center pages horizontally
|
|
||||||
examPages.forEach(page => {
|
|
||||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create exam paper page
|
|
||||||
editor.createPage({
|
|
||||||
id: examPageId,
|
|
||||||
name: 'Exam Paper',
|
|
||||||
});
|
|
||||||
editor.setCurrentPage(examPageId);
|
|
||||||
|
|
||||||
// Create assets and shapes for exam pages
|
|
||||||
examPages.forEach((page) => {
|
|
||||||
editor.createAssets([{
|
|
||||||
id: page.assetId,
|
|
||||||
typeName: 'asset',
|
|
||||||
type: 'image',
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
name: 'PDF Page',
|
|
||||||
src: page.src,
|
|
||||||
isAnimated: false,
|
|
||||||
mimeType: 'image/png',
|
|
||||||
},
|
|
||||||
meta: {},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
editor.createShape({
|
|
||||||
id: page.shapeId,
|
|
||||||
type: 'image',
|
|
||||||
x: page.bounds.x,
|
|
||||||
y: page.bounds.y,
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
assetId: page.assetId,
|
|
||||||
},
|
|
||||||
isLocked: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Similar process for mark scheme pages
|
|
||||||
let markSchemeTop = 0;
|
|
||||||
const markSchemePages = markScheme.pages.map(page => {
|
|
||||||
const width = page.bounds.width;
|
|
||||||
const height = page.bounds.height;
|
|
||||||
const currentTop = markSchemeTop;
|
|
||||||
markSchemeTop += height + PAGE_SPACING;
|
|
||||||
return {
|
|
||||||
...page,
|
|
||||||
bounds: new TLBox((widest - width) / 2, currentTop, width, height)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create mark scheme page
|
|
||||||
editor.createPage({
|
|
||||||
id: markSchemePageId,
|
|
||||||
name: 'Mark Scheme',
|
|
||||||
});
|
|
||||||
editor.setCurrentPage(markSchemePageId);
|
|
||||||
|
|
||||||
// Create assets and shapes for mark scheme pages
|
|
||||||
markSchemePages.forEach((page) => {
|
|
||||||
editor.createAssets([{
|
|
||||||
id: page.assetId,
|
|
||||||
typeName: 'asset',
|
|
||||||
type: 'image',
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
name: 'PDF Page',
|
|
||||||
src: page.src,
|
|
||||||
isAnimated: false,
|
|
||||||
mimeType: 'image/png',
|
|
||||||
},
|
|
||||||
meta: {},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
editor.createShape({
|
|
||||||
id: page.shapeId,
|
|
||||||
type: 'image',
|
|
||||||
x: page.bounds.x,
|
|
||||||
y: page.bounds.y,
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
assetId: page.assetId,
|
|
||||||
},
|
|
||||||
isLocked: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Go back to exam page
|
|
||||||
editor.setCurrentPage(examPageId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setupStudentResponses = async () => {
|
|
||||||
const pagesPerStudent = examPaper.pages.length;
|
|
||||||
const totalStudents = Math.floor(studentResponses.pages.length / pagesPerStudent);
|
|
||||||
|
|
||||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
|
||||||
const startPage = studentIndex * pagesPerStudent;
|
|
||||||
const endPage = startPage + pagesPerStudent;
|
|
||||||
const studentPageId = `page:student-response-${studentIndex}` as TLPageId;
|
|
||||||
|
|
||||||
// Calculate vertical layout
|
|
||||||
let top = 0;
|
|
||||||
let widest = 0;
|
|
||||||
const studentPages = studentResponses.pages
|
|
||||||
.slice(startPage, endPage)
|
|
||||||
.map(page => {
|
|
||||||
const width = page.bounds.width;
|
|
||||||
const height = page.bounds.height;
|
|
||||||
const currentTop = top;
|
|
||||||
top += height + PAGE_SPACING;
|
|
||||||
widest = Math.max(widest, width);
|
|
||||||
return { ...page, top: currentTop, width, height };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Center pages horizontally
|
|
||||||
studentPages.forEach(page => {
|
|
||||||
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create page for this student
|
|
||||||
editor.createPage({
|
|
||||||
id: studentPageId,
|
|
||||||
name: `Student ${studentIndex + 1}`,
|
|
||||||
});
|
|
||||||
editor.setCurrentPage(studentPageId);
|
|
||||||
|
|
||||||
// Create assets and shapes
|
|
||||||
studentPages.forEach((page) => {
|
|
||||||
editor.createAssets([{
|
|
||||||
id: page.assetId,
|
|
||||||
typeName: 'asset',
|
|
||||||
type: 'image',
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
name: 'PDF Page',
|
|
||||||
src: page.src,
|
|
||||||
isAnimated: false,
|
|
||||||
mimeType: 'image/png',
|
|
||||||
},
|
|
||||||
meta: {},
|
|
||||||
}]);
|
|
||||||
|
|
||||||
editor.createShape({
|
|
||||||
id: page.shapeId,
|
|
||||||
type: 'image',
|
|
||||||
x: page.bounds.x,
|
|
||||||
y: page.bounds.y,
|
|
||||||
props: {
|
|
||||||
w: page.bounds.width,
|
|
||||||
h: page.bounds.height,
|
|
||||||
assetId: page.assetId,
|
|
||||||
},
|
|
||||||
isLocked: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initial setup of all pages
|
|
||||||
const setup = async () => {
|
|
||||||
await setupExamAndMarkScheme();
|
|
||||||
await setupStudentResponses();
|
|
||||||
setPagesInitialized(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
setup();
|
|
||||||
}, [editor, pagesInitialized, examPaper, markScheme, studentResponses]);
|
|
||||||
|
|
||||||
// Effect to handle view changes and navigation
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editor || !pagesInitialized) return;
|
|
||||||
|
|
||||||
// Switch to appropriate page based on current view
|
|
||||||
const targetPageId = currentView === 'exam-and-markscheme'
|
|
||||||
? ('page:exam-page' as TLPageId)
|
|
||||||
: (`page:student-response-${currentStudentIndex}` as TLPageId);
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '🔄 Switching view', {
|
|
||||||
currentView,
|
|
||||||
currentStudentIndex,
|
|
||||||
targetPageId
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.setCurrentPage(targetPageId);
|
|
||||||
|
|
||||||
// Update camera constraints for current page
|
|
||||||
const currentPageBounds = Array.from(editor.getCurrentPageShapeIds()).reduce(
|
|
||||||
(acc: TLBox | null, shapeId) => {
|
|
||||||
const bounds = editor.getShapePageBounds(shapeId);
|
|
||||||
return bounds ? (acc ? acc.union(bounds) : bounds) : acc;
|
|
||||||
},
|
|
||||||
null as TLBox | null
|
|
||||||
);
|
|
||||||
|
|
||||||
if (currentPageBounds) {
|
|
||||||
const isMobile = editor.getViewportScreenBounds().width < 840;
|
|
||||||
editor.setCameraOptions({
|
|
||||||
constraints: {
|
|
||||||
bounds: currentPageBounds,
|
|
||||||
padding: { x: isMobile ? 16 : 164, y: 64 },
|
|
||||||
origin: { x: 0.5, y: 0 },
|
|
||||||
initialZoom: 'fit-x-100',
|
|
||||||
baseZoom: 'default',
|
|
||||||
behavior: 'contain',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
editor.setCamera(editor.getCamera(), { reset: true });
|
|
||||||
}
|
|
||||||
}, [editor, pagesInitialized, currentView, currentStudentIndex]);
|
|
||||||
|
|
||||||
// Expose annotationManager to parent through onEditorMount
|
|
||||||
useEffect(() => {
|
|
||||||
if (editor) {
|
|
||||||
onEditorMount(editor);
|
|
||||||
// @ts-expect-error - Adding custom property to editor for CCExamMarkerPanel access
|
|
||||||
editor.annotationManager = annotationManager.current;
|
|
||||||
}
|
|
||||||
}, [editor, onEditorMount]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
||||||
<Tldraw
|
|
||||||
onMount={handleMount}
|
|
||||||
components={{
|
|
||||||
InFrontOfTheCanvas: () => onEditorMount(editor!)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Box, Button, Stack, Typography } from '@mui/material';
|
|
||||||
import { AssetRecordType, Box as TLBox, createShapeId } from '@tldraw/editor';
|
|
||||||
import { ExamPdfs, Pdf, PdfPage } from './types';
|
|
||||||
|
|
||||||
interface CCPdfPickerProps {
|
|
||||||
onOpenPdfs: (pdfs: ExamPdfs) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageSpacing = 32;
|
|
||||||
|
|
||||||
export function CCPdfPicker({ onOpenPdfs }: CCPdfPickerProps) {
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [selectedPdfs, setSelectedPdfs] = useState<Partial<ExamPdfs>>({});
|
|
||||||
|
|
||||||
async function loadPdf(name: string, source: ArrayBuffer): Promise<Pdf> {
|
|
||||||
const PdfJS = await import('pdfjs-dist');
|
|
||||||
PdfJS.GlobalWorkerOptions.workerSrc = new URL(
|
|
||||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
|
||||||
import.meta.url
|
|
||||||
).toString();
|
|
||||||
|
|
||||||
const pdf = await PdfJS.getDocument(source.slice()).promise;
|
|
||||||
const pages: PdfPage[] = [];
|
|
||||||
const canvas = window.document.createElement('canvas');
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) throw new Error('Failed to create canvas context');
|
|
||||||
|
|
||||||
const visualScale = 1.5;
|
|
||||||
const scale = window.devicePixelRatio;
|
|
||||||
let top = 0;
|
|
||||||
let widest = 0;
|
|
||||||
|
|
||||||
for (let i = 1; i <= pdf.numPages; i++) {
|
|
||||||
const page = await pdf.getPage(i);
|
|
||||||
const viewport = page.getViewport({ scale: scale * visualScale });
|
|
||||||
canvas.width = viewport.width;
|
|
||||||
canvas.height = viewport.height;
|
|
||||||
|
|
||||||
const renderContext = {
|
|
||||||
canvasContext: context,
|
|
||||||
viewport,
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.render(renderContext).promise;
|
|
||||||
const width = viewport.width / scale;
|
|
||||||
const height = viewport.height / scale;
|
|
||||||
|
|
||||||
pages.push({
|
|
||||||
src: canvas.toDataURL(),
|
|
||||||
bounds: new TLBox(0, top, width, height),
|
|
||||||
assetId: AssetRecordType.createId(),
|
|
||||||
shapeId: createShapeId(),
|
|
||||||
});
|
|
||||||
|
|
||||||
top += height + pageSpacing;
|
|
||||||
widest = Math.max(widest, width);
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.width = 0;
|
|
||||||
canvas.height = 0;
|
|
||||||
|
|
||||||
for (const page of pages) {
|
|
||||||
page.bounds.x = (widest - page.bounds.width) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
pages,
|
|
||||||
source,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileSelect = async (type: keyof ExamPdfs, file: File) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const pdf = await loadPdf(file.name, await file.arrayBuffer());
|
|
||||||
|
|
||||||
// Validate student responses page count
|
|
||||||
if (type === 'studentResponses' && selectedPdfs.examPaper) {
|
|
||||||
const examPageCount = selectedPdfs.examPaper.pages.length;
|
|
||||||
if (pdf.pages.length % examPageCount !== 0) {
|
|
||||||
alert(`Student responses PDF must have a number of pages that is a multiple of the exam paper's ${examPageCount} pages.\n\nStudent responses PDF has ${pdf.pages.length} pages, which is not a multiple of ${examPageCount}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedPdfs((prev) => ({ ...prev, [type]: pdf }));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading PDF:', error);
|
|
||||||
alert('Error loading PDF (mismatch between responses and exam paper). Please try again.');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createFileInput = (type: keyof ExamPdfs) => {
|
|
||||||
const input = window.document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = 'application/pdf';
|
|
||||||
input.addEventListener('change', async (e) => {
|
|
||||||
const fileList = (e.target as HTMLInputElement).files;
|
|
||||||
if (!fileList || fileList.length === 0) return;
|
|
||||||
await handleFileSelect(type, fileList[0]);
|
|
||||||
});
|
|
||||||
input.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const allPdfsSelected = () => {
|
|
||||||
return (
|
|
||||||
selectedPdfs.examPaper &&
|
|
||||||
selectedPdfs.markScheme &&
|
|
||||||
selectedPdfs.studentResponses
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Box className="CCPdfPicker" sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
<Typography>Loading...</Typography>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="CCPdfPicker" sx={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%'
|
|
||||||
}}>
|
|
||||||
<Stack
|
|
||||||
spacing={4}
|
|
||||||
alignItems="center"
|
|
||||||
sx={{
|
|
||||||
maxWidth: '800px',
|
|
||||||
width: '100%',
|
|
||||||
p: 3
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h5">Select PDF Files</Typography>
|
|
||||||
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
sx={{
|
|
||||||
width: '100%',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 4 // Using MUI's spacing unit (1 unit = 8px, so 4 = 32px)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant={selectedPdfs.examPaper ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => createFileInput('examPaper')}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedPdfs.examPaper ? '✓ Exam Paper' : 'Select Exam Paper'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={selectedPdfs.markScheme ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => createFileInput('markScheme')}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedPdfs.markScheme ? '✓ Mark Scheme' : 'Select Mark Scheme'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={selectedPdfs.studentResponses ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => createFileInput('studentResponses')}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedPdfs.studentResponses ? '✓ Student Responses' : 'Select Student Responses'}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{allPdfsSelected() && (
|
|
||||||
<Box sx={{ mt: 4, width: '100%', display: 'flex', justifyContent: 'center' }}>
|
|
||||||
<Button
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => onOpenPdfs(selectedPdfs as ExamPdfs)}
|
|
||||||
sx={{
|
|
||||||
minWidth: '180px',
|
|
||||||
height: '48px'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
.CCExamMarker {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCPdfPicker {
|
|
||||||
position: absolute;
|
|
||||||
inset: 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCPdfBgRenderer {
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCPdfBgRenderer img {
|
|
||||||
position: absolute;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .PageOverlayScreen-screen {
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: -1;
|
|
||||||
fill: var(--color-background);
|
|
||||||
fill-opacity: 0.8;
|
|
||||||
stroke: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .PageOverlayScreen-outline {
|
|
||||||
position: absolute;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: -1;
|
|
||||||
box-shadow: var(--shadow-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCExportPdfButton {
|
|
||||||
font: inherit;
|
|
||||||
background: var(--color-primary);
|
|
||||||
border: none;
|
|
||||||
color: var(--color-selected-contrast);
|
|
||||||
font-size: 1rem;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 6px;
|
|
||||||
margin-bottom: 0;
|
|
||||||
pointer-events: all;
|
|
||||||
z-index: var(--layer-panels);
|
|
||||||
border: 2px solid var(--color-background);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.CCExamMarker .CCExportPdfButton:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { Box, TLAssetId, TLShapeId } from '@tldraw/tldraw';
|
|
||||||
|
|
||||||
export interface PdfPage {
|
|
||||||
src: string;
|
|
||||||
bounds: Box;
|
|
||||||
assetId: TLAssetId;
|
|
||||||
shapeId: TLShapeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Pdf {
|
|
||||||
name: string;
|
|
||||||
pages: PdfPage[];
|
|
||||||
source: string | ArrayBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamPdfs {
|
|
||||||
examPaper: Pdf;
|
|
||||||
markScheme: Pdf;
|
|
||||||
studentResponses: Pdf;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ExamPdfState =
|
|
||||||
| {
|
|
||||||
phase: 'pick';
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
phase: 'edit';
|
|
||||||
examPaper: Pdf;
|
|
||||||
markScheme: Pdf;
|
|
||||||
studentResponses: Pdf;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface StudentResponse {
|
|
||||||
studentId: string;
|
|
||||||
pageStart: number;
|
|
||||||
pageEnd: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamMetadata {
|
|
||||||
totalPages: number;
|
|
||||||
pagesPerStudent: number;
|
|
||||||
totalStudents: number;
|
|
||||||
studentResponses: StudentResponse[];
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
useTldrawUser,
|
useTldrawUser,
|
||||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||||
TLStore
|
TLStore,
|
||||||
|
createTLStore,
|
||||||
} from '@tldraw/tldraw';
|
} from '@tldraw/tldraw';
|
||||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
@@ -80,80 +81,94 @@ export default function SinglePlayerPage() {
|
|||||||
setUserPreferences: setTldrawPreferences
|
setUserPreferences: setTldrawPreferences
|
||||||
});
|
});
|
||||||
|
|
||||||
// Orchestrated context.node lifecycle — replaces 3 separate effects
|
|
||||||
const [canvasPhase, setCanvasPhase] = useState<
|
const [canvasPhase, setCanvasPhase] = useState<
|
||||||
'idle' | 'store-init' | 'snapshot-loading' | 'node-placing' | 'ready' | 'navigating' | 'error'
|
'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error'
|
||||||
>('idle');
|
>('idle');
|
||||||
const phaseError = useRef<string>('');
|
|
||||||
|
|
||||||
|
// Effect 1: Create the store ONCE per user session. Never recreate on node changes.
|
||||||
|
// Disposing the store while Tldraw is subscribed causes currentPageId crashes; keeping
|
||||||
|
// the store alive for the component lifetime avoids all disposal race conditions.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
const run = async () => {
|
setCanvasPhase('store-init');
|
||||||
try {
|
const store = createTLStore({
|
||||||
setCanvasPhase('store-init');
|
schema: customSchema,
|
||||||
const newStore = localStoreService.getStore({
|
shapeUtils: allShapeUtils,
|
||||||
schema: customSchema,
|
bindingUtils: allBindingUtils,
|
||||||
shapeUtils: allShapeUtils,
|
});
|
||||||
bindingUtils: allBindingUtils
|
// Initialize default tldraw records (TLINSTANCE, page, cameras) before mount
|
||||||
});
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
|
|
||||||
const snapSvc = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
const snapSvc = new NavigationSnapshotService(store, undefined);
|
||||||
if (accessToken) snapSvc.setAccessToken(accessToken);
|
if (accessToken) snapSvc.setAccessToken(accessToken);
|
||||||
snapshotServiceRef.current = snapSvc;
|
snapshotServiceRef.current = snapSvc;
|
||||||
|
|
||||||
if (context.node) {
|
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
setCanvasPhase('snapshot-loading');
|
const unsubscribe = store.listen(() => {
|
||||||
const path = context.node.node_storage_path;
|
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
|
||||||
if (path) {
|
if (debounce) clearTimeout(debounce);
|
||||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
debounce = setTimeout(async () => {
|
||||||
path,
|
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
accessToken || '',
|
}, 2000);
|
||||||
newStore,
|
});
|
||||||
setLoadingState,
|
|
||||||
undefined,
|
|
||||||
editorRef.current || undefined
|
|
||||||
);
|
|
||||||
snapSvc.setCurrentNodePath(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cancelled) {
|
storeRef.current = store;
|
||||||
newStore.dispose();
|
setStoreReady(true);
|
||||||
return;
|
setCanvasPhase('ready');
|
||||||
}
|
|
||||||
|
|
||||||
let debounce: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
newStore.listen(() => {
|
|
||||||
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
|
|
||||||
if (debounce) clearTimeout(debounce);
|
|
||||||
debounce = setTimeout(async () => {
|
|
||||||
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
storeRef.current = newStore;
|
|
||||||
setStoreReady(true);
|
|
||||||
setCanvasPhase('ready');
|
|
||||||
} catch (e) {
|
|
||||||
phaseError.current = e instanceof Error ? e.message : 'Init failed';
|
|
||||||
setCanvasPhase('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
run();
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
unsubscribe?.();
|
||||||
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
snapshotServiceRef.current?.clearCurrentNode();
|
snapshotServiceRef.current?.clearCurrentNode();
|
||||||
snapshotServiceRef.current = null;
|
snapshotServiceRef.current = null;
|
||||||
storeRef.current?.dispose();
|
|
||||||
storeRef.current = null;
|
|
||||||
setStoreReady(false);
|
setStoreReady(false);
|
||||||
setCanvasPhase('idle');
|
setCanvasPhase('idle');
|
||||||
|
store.dispose();
|
||||||
|
storeRef.current = null;
|
||||||
};
|
};
|
||||||
}, [user?.id, context.node?.id]);
|
}, [user?.id]); // Only recreate store if user changes, NOT on every node navigation
|
||||||
|
|
||||||
|
// Effect 2: Load/reload snapshot when context.node changes. Store stays alive.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || !storeRef.current) return;
|
||||||
|
|
||||||
|
const store = storeRef.current;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadNodeSnapshot = async () => {
|
||||||
|
if (!context.node?.node_storage_path) {
|
||||||
|
// No node — clear to blank canvas using ensureStoreIsUsable to restore defaults
|
||||||
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
|
snapshotServiceRef.current?.clearCurrentNode();
|
||||||
|
setCanvasPhase('ready');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCanvasPhase('snapshot-loading');
|
||||||
|
try {
|
||||||
|
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||||
|
context.node.node_storage_path,
|
||||||
|
accessToken || '',
|
||||||
|
store,
|
||||||
|
setLoadingState,
|
||||||
|
undefined,
|
||||||
|
editorRef.current || undefined
|
||||||
|
);
|
||||||
|
if (!cancelled) {
|
||||||
|
// Repair store in case snapshot overwrote TLINSTANCE
|
||||||
|
(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
||||||
|
snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path);
|
||||||
|
setCanvasPhase('ready');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadNodeSnapshot();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready
|
||||||
|
|
||||||
// Handle navigation changes — save previous snapshot, load next one.
|
// Handle navigation changes — save previous snapshot, load next one.
|
||||||
// No automatic node shape placement: the canvas shows only persisted state.
|
// No automatic node shape placement: the canvas shows only persisted state.
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ const DashboardPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Open workspace
|
Open workspace
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() => navigate('/exam-marker')}
|
||||||
|
>
|
||||||
|
Exam Marker
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
onClick={() => navigate('/calendar')}
|
onClick={() => navigate('/calendar')}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* examRepository — the SINGLE module that talks to the /api/exam backend (spec R2.1 seam).
|
||||||
|
*
|
||||||
|
* All exam-marker persistence flows through here so a later dual-write / offline cache can slot
|
||||||
|
* in without touching feature code. Mirrors the auth pattern of timetableService: take the
|
||||||
|
* Supabase session JWT and send it as a Bearer token; the API enforces RLS as the user.
|
||||||
|
*/
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { API_BASE } from '../../config/apiConfig';
|
||||||
|
import { supabase } from '../../supabaseClient';
|
||||||
|
import type {
|
||||||
|
CreateTemplatePayload,
|
||||||
|
ExamTemplate,
|
||||||
|
ExamTemplateDetail,
|
||||||
|
TemplateReplacePayload,
|
||||||
|
} from '../../types/exam.types';
|
||||||
|
|
||||||
|
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||||
|
|
||||||
|
async function authHeaders(): Promise<Record<string, string>> {
|
||||||
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (!session?.access_token) {
|
||||||
|
throw new Error('No authentication token available');
|
||||||
|
}
|
||||||
|
return { Authorization: `Bearer ${session.access_token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const examRepository = {
|
||||||
|
async listTemplates(includeArchived = false): Promise<ExamTemplate[]> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<{ templates: ExamTemplate[] }>(
|
||||||
|
`${EXAM_BASE}/templates`,
|
||||||
|
{ headers, params: { include_archived: includeArchived } },
|
||||||
|
);
|
||||||
|
return res.data.templates ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTemplate(templateId: string): Promise<ExamTemplateDetail> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async replaceTemplate(templateId: string, payload: TemplateReplacePayload): Promise<ExamTemplateDetail> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.put<ExamTemplateDetail>(`${EXAM_BASE}/templates/${templateId}`, payload, { headers });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, { headers, responseType: 'arraybuffer' });
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async archiveTemplate(templateId: string): Promise<void> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
await axios.delete(`${EXAM_BASE}/templates/${templateId}`, { headers });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default examRepository;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// External imports
|
// External imports
|
||||||
import { TLStore, getSnapshot, Editor, loadSnapshot } from '@tldraw/tldraw';
|
import { TLStore, getSnapshot, Editor, loadSnapshot, TLINSTANCE_ID } from '@tldraw/tldraw';
|
||||||
import logger from '../../debugConfig';
|
import logger from '../../debugConfig';
|
||||||
import { SharedStoreService } from './sharedStoreService';
|
import { SharedStoreService } from './sharedStoreService';
|
||||||
|
|
||||||
@@ -109,17 +109,35 @@ export class NavigationSnapshotService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize session.currentPageId against pages actually in the document
|
||||||
|
const docStore = (snap.document as { store?: Record<string, { typeName: string; id: string }> })?.store ?? {};
|
||||||
|
const pageIds = Object.values(docStore).filter((r) => (r as { typeName: string }).typeName === 'page').map((r) => r.id);
|
||||||
|
const session = snap.session as { currentPageId?: string } | undefined;
|
||||||
|
if (session?.currentPageId && pageIds.length > 0 && !pageIds.includes(session.currentPageId)) {
|
||||||
|
logger.warn('snapshot-service', '⚠️ session.currentPageId not in snapshot pages — resetting to first page', {
|
||||||
|
currentPageId: session.currentPageId, availablePages: pageIds
|
||||||
|
});
|
||||||
|
session.currentPageId = pageIds[0];
|
||||||
|
}
|
||||||
|
|
||||||
const snapshotCopy = {
|
const snapshotCopy = {
|
||||||
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
|
schemaVersion: snap.schemaVersion || (snap.document as { schema?: { schemaVersion?: unknown } })?.schema?.schemaVersion,
|
||||||
document: snap.document,
|
document: snap.document,
|
||||||
session: snap.session,
|
session: snap.session,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const targetStore = editor ? editor.store : store;
|
||||||
try {
|
try {
|
||||||
if (editor) {
|
loadSnapshot(targetStore, snapshotCopy as any);
|
||||||
loadSnapshot(editor.store, snapshotCopy as any);
|
// Post-load validation: ensure instance.currentPageId is still valid
|
||||||
} else {
|
const instance = targetStore.get(TLINSTANCE_ID);
|
||||||
loadSnapshot(store, snapshotCopy as any);
|
if (instance) {
|
||||||
|
const pages = targetStore.allRecords().filter((r) => (r as { typeName: string }).typeName === 'page');
|
||||||
|
const pageValid = pages.some((p) => p.id === instance.currentPageId);
|
||||||
|
if (!pageValid) {
|
||||||
|
logger.warn('snapshot-service', '⚠️ Post-load: currentPageId invalid — clearing corrupt store', { currentPageId: instance.currentPageId });
|
||||||
|
targetStore.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
logger.debug('snapshot-service', '✅ Snapshot loaded successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -128,7 +146,8 @@ export class NavigationSnapshotService {
|
|||||||
if (isSchemaMigration) {
|
if (isSchemaMigration) {
|
||||||
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', { error: msg });
|
logger.debug('snapshot-service', 'ℹ️ Schema migration warning (non-critical)', { error: msg });
|
||||||
} else {
|
} else {
|
||||||
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error', { error: msg });
|
logger.warn('snapshot-service', '⚠️ Unexpected loadSnapshot error — clearing store to allow fresh start', { error: msg });
|
||||||
|
targetStore.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Exam-marker types — mirror the FastAPI /api/exam contract (see api routers/exam/schemas.py).
|
||||||
|
* Supabase is source of truth for this operational data; the graph (cc.public.exams) joins by
|
||||||
|
* the shared UUIDs (template/question/region ids, exam_code).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ExamTemplateStatus = 'draft' | 'ready' | 'archived';
|
||||||
|
|
||||||
|
export interface ExamTemplate {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subject: string | null;
|
||||||
|
exam_id: string | null;
|
||||||
|
exam_code: string | null;
|
||||||
|
source_file_id: string | null;
|
||||||
|
page_count: number | null;
|
||||||
|
institute_id: string;
|
||||||
|
teacher_id: string;
|
||||||
|
status: ExamTemplateStatus;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTemplatePayload {
|
||||||
|
title: string;
|
||||||
|
subject?: string;
|
||||||
|
exam_id?: string;
|
||||||
|
exam_code?: string;
|
||||||
|
source_file_id?: string;
|
||||||
|
page_count?: number;
|
||||||
|
institute_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||||
|
export interface ExamQuestion {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
label: string;
|
||||||
|
order: number;
|
||||||
|
max_marks: number;
|
||||||
|
answer_type: string | null;
|
||||||
|
mcq_options: unknown | null;
|
||||||
|
mark_scheme: Record<string, unknown>;
|
||||||
|
is_container: boolean;
|
||||||
|
spec_ref: string | null;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
page?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamResponseArea {
|
||||||
|
id: string;
|
||||||
|
question_id: string;
|
||||||
|
template_id: string;
|
||||||
|
page: number;
|
||||||
|
bounds: Record<string, number>;
|
||||||
|
kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture';
|
||||||
|
response_form: string | null;
|
||||||
|
context_type?: string | null;
|
||||||
|
source: 'manual' | 'ai';
|
||||||
|
confirmed: boolean;
|
||||||
|
confidence: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamBoundary {
|
||||||
|
id: string;
|
||||||
|
template_id: string;
|
||||||
|
question_id: string | null;
|
||||||
|
label: string | null;
|
||||||
|
page_index: number;
|
||||||
|
y: number;
|
||||||
|
bounds: Record<string, number> | null;
|
||||||
|
source: 'manual' | 'ai';
|
||||||
|
confirmed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExamTemplateDetail extends ExamTemplate {
|
||||||
|
questions: ExamQuestion[];
|
||||||
|
response_areas: ExamResponseArea[];
|
||||||
|
boundaries: ExamBoundary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface TemplateReplacePayload {
|
||||||
|
meta?: {
|
||||||
|
title?: string;
|
||||||
|
subject?: string;
|
||||||
|
page_count?: number;
|
||||||
|
status?: ExamTemplateStatus;
|
||||||
|
};
|
||||||
|
questions: Array<{
|
||||||
|
id?: string;
|
||||||
|
parent_id?: string | null;
|
||||||
|
label: string;
|
||||||
|
order?: number;
|
||||||
|
max_marks?: number;
|
||||||
|
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||||
|
mcq_options?: unknown | null;
|
||||||
|
mark_scheme?: Record<string, unknown>;
|
||||||
|
is_container?: boolean;
|
||||||
|
spec_ref?: string | null;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
page?: number | null;
|
||||||
|
}>;
|
||||||
|
response_areas: Array<{
|
||||||
|
id?: string;
|
||||||
|
question_id: string;
|
||||||
|
page: number;
|
||||||
|
bounds: Record<string, number>;
|
||||||
|
kind: ExamResponseArea['kind'];
|
||||||
|
response_form?: string | null;
|
||||||
|
context_type?: string | null;
|
||||||
|
source?: 'manual' | 'ai';
|
||||||
|
confirmed?: boolean;
|
||||||
|
confidence?: number | null;
|
||||||
|
}>;
|
||||||
|
boundaries: Array<{
|
||||||
|
id?: string;
|
||||||
|
question_id?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
page_index: number;
|
||||||
|
y: number;
|
||||||
|
bounds?: Record<string, number> | null;
|
||||||
|
source?: 'manual' | 'ai';
|
||||||
|
confirmed?: boolean;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { ExamTemplateDetail } from '../../types/exam.types'
|
||||||
|
import { serializeCanvasShapes, shapesFromTemplate, type PdfPageLayout } from './model'
|
||||||
|
|
||||||
|
const template: ExamTemplateDetail = {
|
||||||
|
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
||||||
|
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const ids = {
|
||||||
|
top: '11111111-1111-4111-8111-111111111111',
|
||||||
|
bottom: '22222222-2222-4222-8222-222222222222',
|
||||||
|
main: '33333333-3333-4333-8333-333333333333',
|
||||||
|
part: '44444444-4444-4444-8444-444444444444',
|
||||||
|
response: '55555555-5555-4555-8555-555555555555',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('exam setup canvas serialization', () => {
|
||||||
|
it('pairs boundaries into a main question, attaches a Part, and attaches a response by containment', () => {
|
||||||
|
const payload = serializeCanvasShapes(template, [
|
||||||
|
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 100, w: 700, h: 8, label: 'Q1 start' },
|
||||||
|
{ id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 700, w: 700, h: 8, label: 'Q1 end' },
|
||||||
|
{ id: ids.part, kind: 'part', x: 100, y: 180, w: 400, h: 220, label: 'Q1(a)', maxMarks: 3 },
|
||||||
|
{ id: ids.response, kind: 'response', x: 130, y: 250, w: 300, h: 90, responseForm: 'lines' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const main = payload.questions.find((q) => q.is_container)
|
||||||
|
const part = payload.questions.find((q) => q.id === ids.part)
|
||||||
|
expect(main?.label).toBe('Q1')
|
||||||
|
expect(part?.parent_id).toBe(main?.id)
|
||||||
|
expect(part?.bounds).toEqual({ x: 100, y: 180, w: 400, h: 220 })
|
||||||
|
expect(payload.response_areas[0]).toMatchObject({ id: ids.response, question_id: ids.part, kind: 'response', response_form: 'lines' })
|
||||||
|
expect(payload.boundaries).toHaveLength(2)
|
||||||
|
expect(payload.boundaries.every((b) => b.question_id === main?.id)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses real PDF page layouts to save visual page numbers', () => {
|
||||||
|
const layouts: PdfPageLayout[] = [
|
||||||
|
{ page: 1, x: 0, y: 0, w: 780, h: 1000 },
|
||||||
|
{ page: 2, x: 0, y: 1040, w: 780, h: 980 },
|
||||||
|
]
|
||||||
|
const payload = serializeCanvasShapes(template, [
|
||||||
|
{ id: ids.top, kind: 'boundary', questionId: ids.main, x: 40, y: 1060, w: 700, h: 8, label: 'Q2 start' },
|
||||||
|
{ id: ids.bottom, kind: 'boundary', questionId: ids.main, x: 40, y: 1800, w: 700, h: 8, label: 'Q2 end' },
|
||||||
|
{ id: ids.part, kind: 'part', x: 100, y: 1120, w: 400, h: 220, label: 'Q2(a)', maxMarks: 3 },
|
||||||
|
{ id: ids.response, kind: 'response', x: 130, y: 1250, w: 300, h: 90, responseForm: 'lines' },
|
||||||
|
], layouts)
|
||||||
|
|
||||||
|
expect(payload.boundaries.map((b) => b.page_index)).toEqual([1, 1])
|
||||||
|
expect(payload.questions.find((q) => q.id === ids.part)?.page).toBe(2)
|
||||||
|
expect(payload.response_areas[0].page).toBe(2)
|
||||||
|
expect(payload.meta?.page_count).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
|
||||||
|
const shapes = shapesFromTemplate({
|
||||||
|
...template,
|
||||||
|
questions: [
|
||||||
|
{ id: ids.main, template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null },
|
||||||
|
{ id: ids.part, template_id: 'tpl-1', parent_id: ids.main, label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 },
|
||||||
|
],
|
||||||
|
response_areas: [
|
||||||
|
{ id: ids.response, question_id: ids.part, template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null },
|
||||||
|
{ id: '66666666-6666-4666-8666-666666666666', question_id: ids.part, template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null },
|
||||||
|
],
|
||||||
|
boundaries: [{ id: ids.top, template_id: 'tpl-1', question_id: ids.main, label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
|
||||||
|
})
|
||||||
|
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
||||||
|
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: ids.part, x: 1, y: 2, w: 3, h: 4 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exam.types'
|
||||||
|
|
||||||
|
export const PAGE_WIDTH = 780
|
||||||
|
export const DEFAULT_PAGE_HEIGHT = 1100
|
||||||
|
export const PAGE_GAP = 40
|
||||||
|
// Back-compat alias for S4-9a callers/tests. Real PDF pages use PdfPageLayout heights.
|
||||||
|
export const PAGE_HEIGHT = DEFAULT_PAGE_HEIGHT + PAGE_GAP
|
||||||
|
|
||||||
|
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
||||||
|
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
||||||
|
|
||||||
|
export interface CanvasBounds { x: number; y: number; w: number; h: number }
|
||||||
|
export interface PdfPageLayout { page: number; x: number; y: number; w: number; h: number }
|
||||||
|
|
||||||
|
export interface ExamCanvasShapeModel {
|
||||||
|
id: string
|
||||||
|
kind: ExamCanvasShapeKind
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
label?: string
|
||||||
|
maxMarks?: number
|
||||||
|
answerType?: 'written' | 'mcq' | 'short' | 'diagram'
|
||||||
|
responseForm?: 'lines' | 'answer-box' | 'working' | 'diagram' | 'tick-boxes' | 'table' | 'blanks'
|
||||||
|
contextType?: string
|
||||||
|
questionId?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fallbackPageLayouts(pageCount = 4): PdfPageLayout[] {
|
||||||
|
return Array.from({ length: Math.max(pageCount, 1) }, (_, i) => ({
|
||||||
|
page: i + 1,
|
||||||
|
x: 0,
|
||||||
|
y: i * PAGE_HEIGHT,
|
||||||
|
w: PAGE_WIDTH,
|
||||||
|
h: DEFAULT_PAGE_HEIGHT,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageForY(y: number, layouts?: PdfPageLayout[]): number {
|
||||||
|
if (!layouts?.length) return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
|
||||||
|
const hit = layouts.find((layout) => y >= layout.y && y <= layout.y + layout.h)
|
||||||
|
if (hit) return hit.page
|
||||||
|
const nearest = layouts.reduce((best, layout) => {
|
||||||
|
const dist = y < layout.y ? layout.y - y : y - (layout.y + layout.h)
|
||||||
|
return dist < best.dist ? { page: layout.page, dist } : best
|
||||||
|
}, { page: layouts[0].page, dist: Number.POSITIVE_INFINITY })
|
||||||
|
return nearest.page
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, layouts?: PdfPageLayout[]): number {
|
||||||
|
return pageForY(shape.y + shape.h / 2, layouts)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
|
||||||
|
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
|
||||||
|
}
|
||||||
|
|
||||||
|
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
|
||||||
|
const ox2 = outer.x + outer.w
|
||||||
|
const oy2 = outer.y + outer.h
|
||||||
|
const ix2 = inner.x + inner.w
|
||||||
|
const iy2 = inner.y + inner.h
|
||||||
|
return inner.x >= outer.x && inner.y >= outer.y && ix2 <= ox2 && iy2 <= oy2
|
||||||
|
}
|
||||||
|
|
||||||
|
function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, shape: ExamCanvasShapeModel): boolean {
|
||||||
|
const minY = Math.min(top.y, bottom.y)
|
||||||
|
const maxY = Math.max(top.y, bottom.y)
|
||||||
|
const cy = shape.y + shape.h / 2
|
||||||
|
return cy >= minY && cy <= maxY
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuid(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) return crypto.randomUUID()
|
||||||
|
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) =>
|
||||||
|
(Number(c) ^ (Math.random() * 16) >> Number(c) / 4).toString(16),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableUuid(id?: string | null): string {
|
||||||
|
return id && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id) ? id : uuid()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pageLayouts?: PdfPageLayout[]): TemplateReplacePayload {
|
||||||
|
const orderedBoundaries = shapes
|
||||||
|
.filter((s) => s.kind === 'boundary')
|
||||||
|
.sort((a, b) => (pageForShape(a, pageLayouts) - pageForShape(b, pageLayouts)) || (a.y - b.y))
|
||||||
|
const parts = shapes.filter((s) => s.kind === 'part')
|
||||||
|
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
|
||||||
|
|
||||||
|
const questions: TemplateReplacePayload['questions'] = []
|
||||||
|
const boundaries: TemplateReplacePayload['boundaries'] = []
|
||||||
|
const bands: Array<{ questionId: string; top: ExamCanvasShapeModel; bottom: ExamCanvasShapeModel }> = []
|
||||||
|
|
||||||
|
for (let i = 0; i < orderedBoundaries.length; i += 2) {
|
||||||
|
const top = orderedBoundaries[i]
|
||||||
|
const bottom = orderedBoundaries[i + 1]
|
||||||
|
if (!top || !bottom) break
|
||||||
|
const qNum = bands.length + 1
|
||||||
|
const questionId = stableUuid(top.questionId || bottom.questionId)
|
||||||
|
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
|
||||||
|
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
|
||||||
|
bands.push({ questionId, top, bottom })
|
||||||
|
for (const b of [top, bottom]) {
|
||||||
|
boundaries.push({ id: stableUuid(b.id), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pageLayouts) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const partQuestionIds = new Map<string, string>()
|
||||||
|
parts.sort((a, b) => (a.y - b.y) || (a.x - b.x)).forEach((part, index) => {
|
||||||
|
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
||||||
|
const qid = stableUuid(part.questionId || part.id)
|
||||||
|
partQuestionIds.set(part.id, qid)
|
||||||
|
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pageLayouts) })
|
||||||
|
})
|
||||||
|
|
||||||
|
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||||
|
for (const region of regions) {
|
||||||
|
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
||||||
|
const regionPage = pageForShape(region, pageLayouts)
|
||||||
|
const fallbackPart = parts.find((part) => pageForShape(part, pageLayouts) === regionPage) ?? parts[0]
|
||||||
|
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
||||||
|
if (!questionId) continue
|
||||||
|
const kind = region.kind as ExamCanvasRegionKind
|
||||||
|
response_areas.push({ id: stableUuid(region.id), question_id: questionId, page: regionPage, bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: pageLayouts?.length ?? template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeModel[] {
|
||||||
|
const shapes: ExamCanvasShapeModel[] = []
|
||||||
|
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
||||||
|
for (const b of detail.boundaries ?? []) {
|
||||||
|
const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
|
||||||
|
shapes.push({ id: stableUuid(b.id), kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
|
||||||
|
}
|
||||||
|
for (const q of detail.questions ?? []) {
|
||||||
|
if (q.is_container || !q.bounds) continue
|
||||||
|
shapes.push({ id: stableUuid(q.id), kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
|
||||||
|
}
|
||||||
|
for (const r of detail.response_areas ?? []) {
|
||||||
|
const bb = r.bounds ?? { x: 100, y: (r.page - 1) * PAGE_HEIGHT + 360, w: 360, h: 120 }
|
||||||
|
const q = questions.get(r.question_id)
|
||||||
|
shapes.push({ id: stableUuid(r.id), kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? (r.page - 1) * PAGE_HEIGHT + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
|
||||||
|
}
|
||||||
|
return shapes
|
||||||
|
}
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { BasePanel } from './shared/BasePanel';
|
import { BasePanel } from './shared/BasePanel';
|
||||||
import { CCExamMarkerPanel } from './shared/CCExamMarkerPanel';
|
|
||||||
import { BaseContext, ViewContext } from '../../../../types/navigation';
|
import { BaseContext, ViewContext } from '../../../../types/navigation';
|
||||||
|
|
||||||
interface CCPanelProps {
|
interface CCPanelProps {
|
||||||
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
|
|
||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
onExpandedChange?: (expanded: boolean) => void;
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
onPinnedChange?: (pinned: boolean) => void;
|
onPinnedChange?: (pinned: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CCPanel: React.FC<CCPanelProps> = ({
|
export const CCPanel: React.FC<CCPanelProps> = ({
|
||||||
examMarkerProps,
|
|
||||||
isExpanded,
|
isExpanded,
|
||||||
isPinned,
|
isPinned,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
@@ -31,8 +28,7 @@ export const CCPanel: React.FC<CCPanelProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BasePanel
|
<BasePanel
|
||||||
examMarkerProps={examMarkerProps}
|
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
isPinned={isPinned}
|
isPinned={isPinned}
|
||||||
onExpandedChange={handleExpandedChange}
|
onExpandedChange={handleExpandedChange}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useMemo, useState } from 'react';
|
import React, { useEffect, useRef, useMemo, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { TldrawUiButton } from '@tldraw/tldraw';
|
import { TldrawUiButton } from '@tldraw/tldraw';
|
||||||
import { CCShapesPanel } from './CCShapesPanel';
|
import { CCShapesPanel } from './CCShapesPanel';
|
||||||
import { CCSlidesPanel } from './CCSlidesPanel';
|
import { CCSlidesPanel } from './CCSlidesPanel';
|
||||||
@@ -7,7 +6,6 @@ import { CCFilesPanel } from './CCFilesPanel';
|
|||||||
import { CCCabinetsPanel } from './CCCabinetsPanel';
|
import { CCCabinetsPanel } from './CCCabinetsPanel';
|
||||||
import { CCYoutubePanel } from './CCYoutubePanel';
|
import { CCYoutubePanel } from './CCYoutubePanel';
|
||||||
import { CCGraphPanel } from './CCGraphPanel';
|
import { CCGraphPanel } from './CCGraphPanel';
|
||||||
import { CCExamMarkerPanel } from './CCExamMarkerPanel';
|
|
||||||
import { CCSearchPanel } from './CCSearchPanel';
|
import { CCSearchPanel } from './CCSearchPanel';
|
||||||
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel';
|
import { CCGraphNavPanel } from './navigation/CCGraphNavPanel';
|
||||||
import { CCTranscriptionPanel } from './CCTranscriptionPanel';
|
import { CCTranscriptionPanel } from './CCTranscriptionPanel';
|
||||||
@@ -31,16 +29,12 @@ export const PANEL_TYPES = {
|
|||||||
{ id: 'graph', label: 'Graph', order: 60 },
|
{ id: 'graph', label: 'Graph', order: 60 },
|
||||||
{ id: 'search', label: 'Search', order: 70 },
|
{ id: 'search', label: 'Search', order: 70 },
|
||||||
],
|
],
|
||||||
examMarker: [
|
|
||||||
{ id: 'exam-marker', label: 'Exam Marker', order: 10 },
|
|
||||||
],
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type PanelType = typeof PANEL_TYPES.default[number]['id'] | typeof PANEL_TYPES.examMarker[number]['id'];
|
export type PanelType = typeof PANEL_TYPES.default[number]['id'];
|
||||||
|
|
||||||
interface BasePanelProps {
|
interface BasePanelProps {
|
||||||
initialPanelType?: PanelType;
|
initialPanelType?: PanelType;
|
||||||
examMarkerProps?: React.ComponentProps<typeof CCExamMarkerPanel>;
|
|
||||||
isExpanded?: boolean;
|
isExpanded?: boolean;
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
onExpandedChange?: (expanded: boolean) => void;
|
onExpandedChange?: (expanded: boolean) => void;
|
||||||
@@ -125,16 +119,6 @@ function getIconSvg(panelId: PanelType, size = '1.25rem') {
|
|||||||
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
case 'exam-marker':
|
|
||||||
return (
|
|
||||||
<svg {...common}>
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
|
||||||
<polyline points="10 9 9 9 8 9" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<svg {...common}>
|
<svg {...common}>
|
||||||
@@ -152,7 +136,6 @@ function isDarkMode() {
|
|||||||
|
|
||||||
export const BasePanel: React.FC<BasePanelProps> = ({
|
export const BasePanel: React.FC<BasePanelProps> = ({
|
||||||
initialPanelType = 'files',
|
initialPanelType = 'files',
|
||||||
examMarkerProps,
|
|
||||||
isExpanded: controlledIsExpanded,
|
isExpanded: controlledIsExpanded,
|
||||||
isPinned: controlledIsPinned,
|
isPinned: controlledIsPinned,
|
||||||
onExpandedChange,
|
onExpandedChange,
|
||||||
@@ -160,7 +143,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
isMenuOpen = false,
|
isMenuOpen = false,
|
||||||
onMenuOpenChange = () => {},
|
onMenuOpenChange = () => {},
|
||||||
}) => {
|
}) => {
|
||||||
const location = useLocation();
|
|
||||||
const { tldrawPreferences } = useTLDraw();
|
const { tldrawPreferences } = useTLDraw();
|
||||||
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
|
const [prefersDarkMode, setPrefersDarkMode] = useState(isDarkMode());
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
@@ -177,12 +159,9 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
? 'dark'
|
? 'dark'
|
||||||
: 'light';
|
: 'light';
|
||||||
|
|
||||||
const isExamMarkerRoute = location.pathname === '/exam-marker';
|
const availablePanels = PANEL_TYPES.default;
|
||||||
const availablePanels = isExamMarkerRoute ? PANEL_TYPES.examMarker : PANEL_TYPES.default;
|
|
||||||
|
|
||||||
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(
|
const [currentPanelType, setCurrentPanelType] = useState<PanelType>(initialPanelType);
|
||||||
isExamMarkerRoute ? 'exam-marker' : initialPanelType
|
|
||||||
);
|
|
||||||
|
|
||||||
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
|
const [internalIsExpanded, setInternalIsExpanded] = useState(true);
|
||||||
const [internalIsPinned, setInternalIsPinned] = useState(true);
|
const [internalIsPinned, setInternalIsPinned] = useState(true);
|
||||||
@@ -223,10 +202,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
}, [isExpanded, isPinned]);
|
}, [isExpanded, isPinned]);
|
||||||
|
|
||||||
const renderCurrentPanel = () => {
|
const renderCurrentPanel = () => {
|
||||||
if (isExamMarkerRoute && currentPanelType === 'exam-marker') {
|
|
||||||
return examMarkerProps ? <CCExamMarkerPanel {...examMarkerProps} /> : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (currentPanelType) {
|
switch (currentPanelType) {
|
||||||
case 'cabinets':
|
case 'cabinets':
|
||||||
return <CCCabinetsPanel />;
|
return <CCCabinetsPanel />;
|
||||||
@@ -331,7 +306,6 @@ export const BasePanel: React.FC<BasePanelProps> = ({
|
|||||||
{type.id === 'search' && 'Search through your content'}
|
{type.id === 'search' && 'Search through your content'}
|
||||||
{type.id === 'navigation' && 'Navigate through different contexts'}
|
{type.id === 'navigation' && 'Navigate through different contexts'}
|
||||||
{type.id === 'node-snapshot' && 'Manage node snapshots'}
|
{type.id === 'node-snapshot' && 'Manage node snapshots'}
|
||||||
{type.id === 'exam-marker' && 'Mark and grade exams'}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,495 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Box, Button, Typography, Divider, Stack } from '@mui/material';
|
|
||||||
import { Editor, exportToBlob, TLPageId, Box as TLBox } from '@tldraw/tldraw';
|
|
||||||
import { PDFDocument } from 'pdf-lib';
|
|
||||||
import { Pdf } from '../../../../../pages/tldraw/CCExamMarker/types';
|
|
||||||
import { logger } from '../../../../../debugConfig';
|
|
||||||
|
|
||||||
interface CCExamMarkerPanelProps {
|
|
||||||
editor: Editor | null;
|
|
||||||
currentView: 'exam-and-markscheme' | 'student-responses';
|
|
||||||
onViewChange: (view: 'exam-and-markscheme' | 'student-responses') => void;
|
|
||||||
currentStudentIndex: number;
|
|
||||||
totalStudents: number;
|
|
||||||
onPreviousStudent: () => void;
|
|
||||||
onNextStudent: () => void;
|
|
||||||
getCurrentPdf: () => Pdf | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CCExamMarkerPanel: React.FC<CCExamMarkerPanelProps> = ({
|
|
||||||
editor,
|
|
||||||
currentView,
|
|
||||||
onViewChange,
|
|
||||||
currentStudentIndex,
|
|
||||||
totalStudents,
|
|
||||||
onPreviousStudent,
|
|
||||||
onNextStudent,
|
|
||||||
getCurrentPdf,
|
|
||||||
}) => {
|
|
||||||
const [exportProgress, setExportProgress] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const exportPdf = async (
|
|
||||||
editor: Editor,
|
|
||||||
{ name, source, pages }: Pdf,
|
|
||||||
onProgress: (progress: number) => void,
|
|
||||||
startPage?: number,
|
|
||||||
endPage?: number,
|
|
||||||
studentIndex?: number
|
|
||||||
) => {
|
|
||||||
logger.debug('cc-exam-marker', '📤 Starting PDF export', {
|
|
||||||
name,
|
|
||||||
startPage,
|
|
||||||
endPage,
|
|
||||||
studentIndex,
|
|
||||||
currentView,
|
|
||||||
totalPages: pages.length
|
|
||||||
});
|
|
||||||
|
|
||||||
const pdfPages = pages.slice(startPage, endPage);
|
|
||||||
logger.debug('cc-exam-marker', '📄 Selected pages for export', {
|
|
||||||
pdfPages: pdfPages.length,
|
|
||||||
pageIndices: pdfPages.map((_, i) => (startPage || 0) + i)
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalThings = pdfPages.length * 2 + 2;
|
|
||||||
let progressCount = 0;
|
|
||||||
const tickProgress = () => {
|
|
||||||
progressCount++;
|
|
||||||
onProgress(progressCount / totalThings);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sourcePdf = await PDFDocument.load(source);
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
// Create a new PDF document for the selected pages
|
|
||||||
const newPdf = await PDFDocument.create();
|
|
||||||
|
|
||||||
// Copy pages from source PDF
|
|
||||||
const pageIndices = pdfPages.map((_, i) => (startPage || 0) + i);
|
|
||||||
const copiedPages = await newPdf.copyPages(sourcePdf, pageIndices);
|
|
||||||
copiedPages.forEach(page => newPdf.addPage(page));
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
// Store current page to restore later
|
|
||||||
const currentPageId = editor.getCurrentPageId();
|
|
||||||
logger.debug('cc-exam-marker', '📍 Current page before export', { currentPageId });
|
|
||||||
|
|
||||||
// Switch to the correct page based on context
|
|
||||||
const targetPageId = (studentIndex !== undefined
|
|
||||||
? `page:student-response-${studentIndex}`
|
|
||||||
: currentView === 'exam-and-markscheme'
|
|
||||||
? 'page:exam-page'
|
|
||||||
: 'page:mark-scheme-page') as TLPageId;
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '🎯 Switching to target page', { targetPageId });
|
|
||||||
editor.setCurrentPage(targetPageId);
|
|
||||||
|
|
||||||
// Get all shape IDs that are not page shapes (i.e., annotations)
|
|
||||||
const pageShapeIds = new Set(pages.map(page => page.shapeId));
|
|
||||||
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '📝 Found shapes on current page', {
|
|
||||||
totalShapes: editor.getCurrentPageShapeIds().size,
|
|
||||||
pageShapes: pageShapeIds.size,
|
|
||||||
annotationShapes: allShapeIds.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// For each page, draw annotations on top
|
|
||||||
for (let i = 0; i < pdfPages.length; i++) {
|
|
||||||
const page = pdfPages[i];
|
|
||||||
const pdfPage = newPdf.getPages()[i];
|
|
||||||
const {bounds} = page;
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', `📄 Processing page ${i + 1}/${pdfPages.length}`, {
|
|
||||||
bounds,
|
|
||||||
pageIndex: i,
|
|
||||||
globalPageIndex: (startPage || 0) + i
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get shapes that intersect with this page using editor's bounds checking
|
|
||||||
const shapesInBounds = allShapeIds.filter((id) => {
|
|
||||||
const shape = editor.getShape(id);
|
|
||||||
if (!shape || shape.isLocked) return false;
|
|
||||||
|
|
||||||
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
|
|
||||||
const annotationManager = editor.annotationManager;
|
|
||||||
const annotationData = annotationManager.getAnnotationData(id);
|
|
||||||
if (!annotationData) return false;
|
|
||||||
|
|
||||||
// Filter by student index if provided
|
|
||||||
if (studentIndex !== undefined && annotationData.studentIndex !== studentIndex) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For exam/markscheme view, only include those annotations
|
|
||||||
if (studentIndex === undefined && annotationData.studentIndex !== undefined) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For individual student exports, use the annotation's original page index
|
|
||||||
// For full exports, use the stored page index
|
|
||||||
const adjustedPageIndex = annotationData.pageIndex;
|
|
||||||
|
|
||||||
// Check if this shape belongs to this page index
|
|
||||||
if (adjustedPageIndex !== i) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', `🔍 Found matching annotation`, {
|
|
||||||
shapeId: id,
|
|
||||||
annotationData,
|
|
||||||
adjustedPageIndex,
|
|
||||||
currentPageIndex: i,
|
|
||||||
bounds: editor.getShapePageBounds(id)
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', `✨ Found shapes for page ${i + 1}`, {
|
|
||||||
shapesInBounds: shapesInBounds.length,
|
|
||||||
pageIndex: i,
|
|
||||||
globalPageIndex: (startPage || 0) + i
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shapesInBounds.length === 0) {
|
|
||||||
tickProgress();
|
|
||||||
tickProgress();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export the annotations as PNG
|
|
||||||
const exportedPng = await exportToBlob({
|
|
||||||
editor,
|
|
||||||
ids: shapesInBounds,
|
|
||||||
format: 'png',
|
|
||||||
opts: {
|
|
||||||
background: false,
|
|
||||||
// Create a new bounds that's relative to the current page
|
|
||||||
bounds: new TLBox(
|
|
||||||
bounds.x,
|
|
||||||
0, // Reset to 0 since we want annotations relative to current page
|
|
||||||
bounds.width,
|
|
||||||
bounds.height
|
|
||||||
),
|
|
||||||
padding: 0,
|
|
||||||
scale: 1
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
// Draw the annotations on the PDF page
|
|
||||||
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
|
|
||||||
const pdfWidth = pdfPage.getWidth();
|
|
||||||
const pdfHeight = pdfPage.getHeight();
|
|
||||||
|
|
||||||
pdfPage.drawImage(pngImage, {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: pdfWidth,
|
|
||||||
height: pdfHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
tickProgress();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore original page
|
|
||||||
logger.debug('cc-exam-marker', '🔄 Restoring original page', { currentPageId });
|
|
||||||
editor.setCurrentPage(currentPageId);
|
|
||||||
|
|
||||||
const pdfBytes = await newPdf.save();
|
|
||||||
const url = URL.createObjectURL(
|
|
||||||
new Blob([pdfBytes] as unknown as BlobPart[], { type: 'application/pdf' })
|
|
||||||
);
|
|
||||||
tickProgress();
|
|
||||||
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = name;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
logger.debug('cc-exam-marker', '✅ PDF export completed', { name });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportCurrentView = async () => {
|
|
||||||
if (!editor) return;
|
|
||||||
const currentPdf = getCurrentPdf();
|
|
||||||
if (!currentPdf) return;
|
|
||||||
|
|
||||||
setExportProgress(0);
|
|
||||||
try {
|
|
||||||
if (currentView === 'student-responses') {
|
|
||||||
// For student responses, we need to handle each student's annotations separately
|
|
||||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
|
||||||
let currentProgress = 0;
|
|
||||||
|
|
||||||
// Create a new PDF with all pages
|
|
||||||
const sourcePdf = await PDFDocument.load(currentPdf.source);
|
|
||||||
const newPdf = await PDFDocument.create();
|
|
||||||
const copiedPages = await newPdf.copyPages(sourcePdf, Array.from({ length: currentPdf.pages.length }, (_, i) => i));
|
|
||||||
copiedPages.forEach(page => newPdf.addPage(page));
|
|
||||||
|
|
||||||
// For each student, export their annotations onto their pages
|
|
||||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
|
||||||
const startPage = studentIndex * pagesPerStudent;
|
|
||||||
|
|
||||||
// Switch to the student's page to get their annotations
|
|
||||||
const targetPageId = `page:student-response-${studentIndex}` as TLPageId;
|
|
||||||
editor.setCurrentPage(targetPageId);
|
|
||||||
|
|
||||||
// Get all annotations for this student
|
|
||||||
const pageShapeIds = new Set(currentPdf.pages.map(page => page.shapeId));
|
|
||||||
const allShapeIds = Array.from(editor.getCurrentPageShapeIds()).filter(id => !pageShapeIds.has(id));
|
|
||||||
|
|
||||||
// Process each page for this student
|
|
||||||
for (let i = 0; i < pagesPerStudent; i++) {
|
|
||||||
const pageIndex = startPage + i;
|
|
||||||
const page = currentPdf.pages[pageIndex];
|
|
||||||
const pdfPage = newPdf.getPages()[pageIndex];
|
|
||||||
|
|
||||||
// Get shapes for this page
|
|
||||||
const shapesInBounds = allShapeIds.filter((id) => {
|
|
||||||
const shape = editor.getShape(id);
|
|
||||||
if (!shape || shape.isLocked) return false;
|
|
||||||
|
|
||||||
// @ts-expect-error - annotationManager is added to editor in CCPdfEditor
|
|
||||||
const annotationManager = editor.annotationManager;
|
|
||||||
const annotationData = annotationManager.getAnnotationData(id);
|
|
||||||
if (!annotationData) return false;
|
|
||||||
|
|
||||||
return annotationData.studentIndex === studentIndex && annotationData.pageIndex === i;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shapesInBounds.length > 0) {
|
|
||||||
// Export and draw annotations
|
|
||||||
const exportedPng = await exportToBlob({
|
|
||||||
editor,
|
|
||||||
ids: shapesInBounds,
|
|
||||||
format: 'png',
|
|
||||||
opts: {
|
|
||||||
background: false,
|
|
||||||
bounds: new TLBox(
|
|
||||||
page.bounds.x,
|
|
||||||
0,
|
|
||||||
page.bounds.width,
|
|
||||||
page.bounds.height
|
|
||||||
),
|
|
||||||
padding: 0,
|
|
||||||
scale: 1
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const pngImage = await newPdf.embedPng(await exportedPng.arrayBuffer());
|
|
||||||
pdfPage.drawImage(pngImage, {
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: pdfPage.getWidth(),
|
|
||||||
height: pdfPage.getHeight(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
currentProgress++;
|
|
||||||
setExportProgress(currentProgress / (totalStudents * pagesPerStudent));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the combined PDF
|
|
||||||
const pdfBytes = await newPdf.save();
|
|
||||||
const url = URL.createObjectURL(new Blob([pdfBytes] as unknown as BlobPart[], { type: 'application/pdf' }));
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = currentPdf.name;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
} else {
|
|
||||||
// For exam/mark scheme view, use the original export logic
|
|
||||||
await exportPdf(editor, currentPdf, setExportProgress);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setExportProgress(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportCurrentStudent = async () => {
|
|
||||||
if (!editor || currentView !== 'student-responses') return;
|
|
||||||
const currentPdf = getCurrentPdf();
|
|
||||||
if (!currentPdf) return;
|
|
||||||
|
|
||||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
|
||||||
const startPage = currentStudentIndex * pagesPerStudent;
|
|
||||||
const endPage = startPage + pagesPerStudent;
|
|
||||||
|
|
||||||
setExportProgress(0);
|
|
||||||
try {
|
|
||||||
await exportPdf(
|
|
||||||
editor,
|
|
||||||
{
|
|
||||||
...currentPdf,
|
|
||||||
name: `Student_${currentStudentIndex + 1}_Response.pdf`,
|
|
||||||
},
|
|
||||||
setExportProgress,
|
|
||||||
Math.floor(startPage),
|
|
||||||
Math.floor(endPage),
|
|
||||||
currentStudentIndex
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setExportProgress(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBatchExport = async () => {
|
|
||||||
if (!editor || currentView !== 'student-responses') return;
|
|
||||||
const currentPdf = getCurrentPdf();
|
|
||||||
if (!currentPdf) return;
|
|
||||||
|
|
||||||
setExportProgress(0);
|
|
||||||
try {
|
|
||||||
const pagesPerStudent = currentPdf.pages.length / totalStudents;
|
|
||||||
let currentProgress = 0;
|
|
||||||
|
|
||||||
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
|
|
||||||
const startPage = studentIndex * pagesPerStudent;
|
|
||||||
const endPage = startPage + pagesPerStudent;
|
|
||||||
|
|
||||||
await exportPdf(
|
|
||||||
editor,
|
|
||||||
{
|
|
||||||
...currentPdf,
|
|
||||||
name: `Student_${studentIndex + 1}_Response.pdf`,
|
|
||||||
},
|
|
||||||
setExportProgress,
|
|
||||||
Math.floor(startPage),
|
|
||||||
Math.floor(endPage),
|
|
||||||
studentIndex
|
|
||||||
);
|
|
||||||
|
|
||||||
currentProgress++;
|
|
||||||
setExportProgress(currentProgress / totalStudents);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setExportProgress(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
|
||||||
Exam Marker
|
|
||||||
</Typography>
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
|
||||||
View Mode
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant={currentView === 'exam-and-markscheme' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => onViewChange('exam-and-markscheme')}
|
|
||||||
>
|
|
||||||
Exam & Mark Scheme
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant={currentView === 'student-responses' ? 'contained' : 'outlined'}
|
|
||||||
onClick={() => onViewChange('student-responses')}
|
|
||||||
>
|
|
||||||
Student Responses
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{currentView === 'student-responses' && (
|
|
||||||
<>
|
|
||||||
<Divider />
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
|
||||||
Student Navigation
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onPreviousStudent}
|
|
||||||
disabled={currentStudentIndex === 0}
|
|
||||||
>
|
|
||||||
Previous Student
|
|
||||||
</Button>
|
|
||||||
<Typography variant="body2" align="center">
|
|
||||||
Student {currentStudentIndex + 1} of {totalStudents}
|
|
||||||
</Typography>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
onClick={onNextStudent}
|
|
||||||
disabled={currentStudentIndex === totalStudents - 1}
|
|
||||||
>
|
|
||||||
Next Student
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
|
||||||
Actions
|
|
||||||
</Typography>
|
|
||||||
<Stack spacing={1}>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
|
||||||
onClick={handleExportCurrentView}
|
|
||||||
>
|
|
||||||
{exportProgress !== null
|
|
||||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
|
||||||
: `Export ${currentView === 'exam-and-markscheme' ? 'All' : 'Combined Responses'}`}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{currentView === 'student-responses' && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
|
||||||
onClick={handleExportCurrentStudent}
|
|
||||||
>
|
|
||||||
{exportProgress !== null
|
|
||||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
|
||||||
: 'Export Current Student'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
disabled={!editor || !getCurrentPdf() || exportProgress !== null}
|
|
||||||
onClick={handleBatchExport}
|
|
||||||
>
|
|
||||||
{exportProgress !== null
|
|
||||||
? `Exporting... ${Math.round(exportProgress * 100)}%`
|
|
||||||
: 'Export All as Separate Files'}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ mt: 'auto' }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
|
||||||
Statistics
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
Total Pages: {getCurrentPdf()?.pages.length || 0}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user