Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5f073eb91 |
@@ -35,6 +35,7 @@ vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div>
|
||||
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
||||
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
||||
vi.mock('./pages/tldraw/ExamMarkerSpikePage', () => ({ default: () => <div>Exam Marker Spike</div> }));
|
||||
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
|
||||
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
|
||||
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
|
||||
|
||||
+3
-2
@@ -7,7 +7,7 @@ import LoginPage from './pages/auth/loginPage';
|
||||
import SignupPage from './pages/auth/signupPage';
|
||||
import SinglePlayerPage from './pages/tldraw/singlePlayerPage';
|
||||
import MultiplayerUser from './pages/tldraw/multiplayerUser';
|
||||
import { ExamDashboardPage, ExamTemplateSetupPage } from './pages/exam';
|
||||
import { ExamDashboardPage } from './pages/exam';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import CalendarPage from './pages/user/calendarPage';
|
||||
import SettingsPage from './pages/user/settingsPage';
|
||||
@@ -16,6 +16,7 @@ import AdminDashboard from './pages/auth/adminPage';
|
||||
import PlatformAdminPage from './pages/auth/PlatformAdminPage';
|
||||
import TLDrawDevPage from './pages/tldraw/devPlayerPage';
|
||||
import DevPage from './pages/tldraw/devPage';
|
||||
import ExamMarkerSpikePage from './pages/tldraw/ExamMarkerSpikePage';
|
||||
import TeacherPlanner from './pages/react-flow/teacherPlanner';
|
||||
import MorphicPage from './pages/morphicPage';
|
||||
import NotFound from './pages/user/NotFound';
|
||||
@@ -183,10 +184,10 @@ const AppRoutes: React.FC = () => {
|
||||
<Route path="/search" element={<SearxngPage />} />
|
||||
<Route path="/teacher-planner" element={<TeacherPlanner />} />
|
||||
<Route path="/exam-marker" element={<ErrorBoundary><ExamDashboardPage /></ErrorBoundary>} />
|
||||
<Route path="/exam-marker/:templateId/setup" element={<ErrorBoundary><ExamTemplateSetupPage /></ErrorBoundary>} />
|
||||
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||
<Route path="/morphic" element={<MorphicPage />} />
|
||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||
<Route path="/exam-marker-spike" element={<ExamMarkerSpikePage />} />
|
||||
<Route path="/dev" element={<DevPage />} />
|
||||
<Route path="/dev/upload-test" element={<SimpleUploadTest />} />
|
||||
<Route path="/single-player" element={<SinglePlayerPage />} />
|
||||
|
||||
@@ -46,6 +46,7 @@ const ExamDashboardPage: React.FC = () => {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [subject, setSubject] = useState('');
|
||||
const [sourcePdf, setSourcePdf] = useState<File | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -66,6 +67,18 @@ const ExamDashboardPage: React.FC = () => {
|
||||
void load();
|
||||
}, [load, instituteId]);
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setCreateOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseCreate = () => {
|
||||
if (saving) return;
|
||||
setCreateOpen(false);
|
||||
setTitle('');
|
||||
setSubject('');
|
||||
setSourcePdf(null);
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
@@ -74,10 +87,12 @@ const ExamDashboardPage: React.FC = () => {
|
||||
title: title.trim(),
|
||||
subject: subject.trim() || undefined,
|
||||
institute_id: instituteId,
|
||||
source_pdf: sourcePdf,
|
||||
});
|
||||
setCreateOpen(false);
|
||||
setTitle('');
|
||||
setSubject('');
|
||||
setSourcePdf(null);
|
||||
navigate(`/exam-marker/${created.id}/setup`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
@@ -112,7 +127,7 @@ const ExamDashboardPage: React.FC = () => {
|
||||
Build a template for an exam paper, then run marking batches against your classes.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenCreate}>
|
||||
New template
|
||||
</Button>
|
||||
</Box>
|
||||
@@ -134,7 +149,7 @@ const ExamDashboardPage: React.FC = () => {
|
||||
<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)}>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={handleOpenCreate}>
|
||||
New template
|
||||
</Button>
|
||||
</Paper>
|
||||
@@ -144,8 +159,16 @@ const ExamDashboardPage: React.FC = () => {
|
||||
<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 } }}
|
||||
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' }}>
|
||||
@@ -175,7 +198,7 @@ const ExamDashboardPage: React.FC = () => {
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
||||
<Dialog open={createOpen} onClose={handleCloseCreate} fullWidth maxWidth="sm">
|
||||
<DialogTitle>New exam template</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
@@ -193,10 +216,24 @@ const ExamDashboardPage: React.FC = () => {
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<Box>
|
||||
<Button variant="outlined" component="label">
|
||||
{sourcePdf ? 'Replace source PDF' : 'Attach source PDF'}
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={(e) => setSourcePdf(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{sourcePdf ? sourcePdf.name : 'Optional: upload a PDF source for this template.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleCloseCreate} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
||||
{saving ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export { default as ExamDashboardPage } from './ExamDashboardPage';
|
||||
export { default as ExamTemplateSetupPage } from './setup/ExamTemplateSetupPage';
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
|
||||
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
|
||||
@@ -1,123 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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,
|
||||
},
|
||||
})))
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Tldraw,
|
||||
createTLStore,
|
||||
createTLSchemaFromUtils,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
Editor,
|
||||
TLAnyBindingUtilConstructor,
|
||||
TLAnyShapeUtilConstructor,
|
||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||
} from '@tldraw/tldraw'
|
||||
import { allBindingUtils } from '../../utils/tldraw/bindings'
|
||||
import { allShapeUtils } from '../../utils/tldraw/shapes'
|
||||
import { customAssets } from '../../utils/tldraw/assets'
|
||||
import { getUiComponents, getUiOverrides } from '../../utils/tldraw/ui-overrides'
|
||||
import { HEADER_HEIGHT } from '../Layout'
|
||||
import { devTools } from '../../utils/tldraw/tools'
|
||||
import {
|
||||
activateExamMarkerTool,
|
||||
EXAM_MARKER_BOX_TYPE,
|
||||
ExamMarkerBoxShapeUtil,
|
||||
ExamMarkerBoxTool,
|
||||
placeExamMarkerSpike,
|
||||
} from '../../utils/tldraw/exam-marker-spike'
|
||||
|
||||
const spikeShapeUtils = [...allShapeUtils, ExamMarkerBoxShapeUtil] as TLAnyShapeUtilConstructor[]
|
||||
const spikeBindingUtils = allBindingUtils as TLAnyBindingUtilConstructor[]
|
||||
const spikeTools = [...devTools, ExamMarkerBoxTool]
|
||||
|
||||
const spikeSchema = createTLSchemaFromUtils({
|
||||
shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils],
|
||||
bindingUtils: [...defaultBindingUtils, ...spikeBindingUtils],
|
||||
})
|
||||
|
||||
export default function ExamMarkerSpikePage() {
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
const [status, setStatus] = useState('Ready to insert an image and a custom exam-marker box.')
|
||||
|
||||
const store = useMemo(() => {
|
||||
const nextStore = createTLStore({
|
||||
schema: spikeSchema,
|
||||
shapeUtils: spikeShapeUtils,
|
||||
bindingUtils: spikeBindingUtils,
|
||||
})
|
||||
;(nextStore as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable()
|
||||
return nextStore
|
||||
}, [])
|
||||
|
||||
const onMount = (editor: Editor) => {
|
||||
editorRef.current = editor
|
||||
}
|
||||
|
||||
const insertSpike = () => {
|
||||
if (!editorRef.current) return
|
||||
const { imageId, boxId } = placeExamMarkerSpike(editorRef.current)
|
||||
const toolId = activateExamMarkerTool(editorRef.current)
|
||||
setStatus(`Inserted image ${imageId} and custom box ${boxId}; active tool is ${toolId}.`)
|
||||
}
|
||||
|
||||
const focusBox = () => {
|
||||
if (!editorRef.current) return
|
||||
const current = editorRef.current.getCurrentPageShapes().find((shape) => shape.type === EXAM_MARKER_BOX_TYPE)
|
||||
if (!current) {
|
||||
setStatus('No exam-marker box found yet.')
|
||||
return
|
||||
}
|
||||
const bounds = editorRef.current.getShapePageBounds(current)
|
||||
if (bounds) {
|
||||
editorRef.current.zoomToBounds(bounds)
|
||||
setStatus('Focused the camera on the exam-marker box using getShapePageBounds + zoomToBounds.')
|
||||
}
|
||||
}
|
||||
|
||||
const uiOverrides = getUiOverrides(false)
|
||||
const uiComponents = getUiComponents(false)
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0, top: HEADER_HEIGHT, background: '#f8fafc' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 10,
|
||||
top: 16,
|
||||
left: 16,
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 16,
|
||||
background: 'rgba(15, 23, 42, 0.9)',
|
||||
color: 'white',
|
||||
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.22)',
|
||||
maxWidth: 880,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<strong style={{ fontSize: 14 }}>Exam-marker spike</strong>
|
||||
<span style={{ fontSize: 12, opacity: 0.9 }}>{status}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={insertSpike}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: 'none',
|
||||
background: '#f59e0b',
|
||||
color: '#111827',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Insert image + marker box
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!editorRef.current) return
|
||||
const toolId = activateExamMarkerTool(editorRef.current)
|
||||
setStatus(`Activated tool ${toolId}. Drag on the canvas to create another marker box.`)
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
background: 'transparent',
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Activate box tool
|
||||
</button>
|
||||
<button
|
||||
onClick={focusBox}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
background: 'transparent',
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Focus marker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tldraw
|
||||
store={store}
|
||||
tools={spikeTools}
|
||||
shapeUtils={spikeShapeUtils}
|
||||
bindingUtils={spikeBindingUtils}
|
||||
components={uiComponents}
|
||||
overrides={uiOverrides}
|
||||
assetUrls={customAssets}
|
||||
autoFocus
|
||||
hideUi={false}
|
||||
inferDarkMode={false}
|
||||
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
|
||||
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
|
||||
maxImageDimension={Infinity}
|
||||
maxAssetSize={100 * 1024 * 1024}
|
||||
onMount={onMount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
CreateTemplatePayload,
|
||||
ExamTemplate,
|
||||
ExamTemplateDetail,
|
||||
TemplateReplacePayload,
|
||||
} from '../../types/exam.types';
|
||||
|
||||
const EXAM_BASE = `${API_BASE}/api/exam`;
|
||||
@@ -44,22 +43,28 @@ export const examRepository = {
|
||||
|
||||
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||
const headers = await authHeaders();
|
||||
|
||||
if (payload.source_pdf) {
|
||||
const form = new FormData();
|
||||
form.append('title', payload.title);
|
||||
if (payload.subject) form.append('subject', payload.subject);
|
||||
if (payload.exam_id) form.append('exam_id', payload.exam_id);
|
||||
if (payload.exam_code) form.append('exam_code', payload.exam_code);
|
||||
if (payload.source_file_id) form.append('source_file_id', payload.source_file_id);
|
||||
if (payload.page_count !== undefined) form.append('page_count', String(payload.page_count));
|
||||
if (payload.institute_id) form.append('institute_id', payload.institute_id);
|
||||
form.append('source_pdf', payload.source_pdf);
|
||||
|
||||
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, form, {
|
||||
headers,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async 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 });
|
||||
|
||||
+2
-50
@@ -27,6 +27,7 @@ export interface CreateTemplatePayload {
|
||||
exam_id?: string;
|
||||
exam_code?: string;
|
||||
source_file_id?: string;
|
||||
source_pdf?: File | null;
|
||||
page_count?: number;
|
||||
institute_id?: string;
|
||||
}
|
||||
@@ -44,8 +45,6 @@ export interface ExamQuestion {
|
||||
mark_scheme: Record<string, unknown>;
|
||||
is_container: boolean;
|
||||
spec_ref: string | null;
|
||||
bounds?: Record<string, number> | null;
|
||||
page?: number | null;
|
||||
}
|
||||
|
||||
export interface ExamResponseArea {
|
||||
@@ -54,9 +53,8 @@ export interface ExamResponseArea {
|
||||
template_id: string;
|
||||
page: number;
|
||||
bounds: Record<string, number>;
|
||||
kind: 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture';
|
||||
kind: 'response' | 'context';
|
||||
response_form: string | null;
|
||||
context_type?: string | null;
|
||||
source: 'manual' | 'ai';
|
||||
confirmed: boolean;
|
||||
confidence: number | null;
|
||||
@@ -79,49 +77,3 @@ export interface ExamTemplateDetail extends ExamTemplate {
|
||||
response_areas: ExamResponseArea[];
|
||||
boundaries: ExamBoundary[];
|
||||
}
|
||||
|
||||
|
||||
export interface TemplateReplacePayload {
|
||||
meta?: {
|
||||
title?: string;
|
||||
subject?: string;
|
||||
page_count?: number;
|
||||
status?: ExamTemplateStatus;
|
||||
};
|
||||
questions: Array<{
|
||||
id?: string;
|
||||
parent_id?: string | null;
|
||||
label: string;
|
||||
order?: number;
|
||||
max_marks?: number;
|
||||
answer_type?: 'written' | 'mcq' | 'short' | 'diagram' | null;
|
||||
mcq_options?: unknown | null;
|
||||
mark_scheme?: Record<string, unknown>;
|
||||
is_container?: boolean;
|
||||
spec_ref?: string | null;
|
||||
bounds?: Record<string, number> | null;
|
||||
page?: number | null;
|
||||
}>;
|
||||
response_areas: Array<{
|
||||
id?: string;
|
||||
question_id: string;
|
||||
page: number;
|
||||
bounds: Record<string, number>;
|
||||
kind: ExamResponseArea['kind'];
|
||||
response_form?: string | null;
|
||||
context_type?: string | null;
|
||||
source?: 'manual' | 'ai';
|
||||
confirmed?: boolean;
|
||||
confidence?: number | null;
|
||||
}>;
|
||||
boundaries: Array<{
|
||||
id?: string;
|
||||
question_id?: string | null;
|
||||
label?: string | null;
|
||||
page_index: number;
|
||||
y: number;
|
||||
bounds?: Record<string, number> | null;
|
||||
source?: 'manual' | 'ai';
|
||||
confirmed?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -1,149 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
Editor,
|
||||
createTLStore,
|
||||
createTLSchemaFromUtils,
|
||||
defaultBindingUtils,
|
||||
defaultShapeUtils,
|
||||
} from '@tldraw/tldraw'
|
||||
import {
|
||||
EXAM_MARKER_BOX_TYPE,
|
||||
ExamMarkerBoxShapeUtil,
|
||||
ExamMarkerBoxTool,
|
||||
activateExamMarkerTool,
|
||||
buildExamMarkerSvgDataUrl,
|
||||
getExamMarkerHitTestPoints,
|
||||
insertExamMarkerBox,
|
||||
} from './exam-marker-spike'
|
||||
|
||||
const spikeShapeUtils = [ExamMarkerBoxShapeUtil]
|
||||
const spikeSchema = createTLSchemaFromUtils({
|
||||
shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils],
|
||||
bindingUtils: defaultBindingUtils,
|
||||
})
|
||||
|
||||
if (typeof globalThis.matchMedia !== 'function') {
|
||||
vi.stubGlobal('matchMedia', () => ({
|
||||
matches: false,
|
||||
media: '',
|
||||
onchange: null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
dispatchEvent: () => false,
|
||||
} as unknown as MediaQueryList))
|
||||
}
|
||||
|
||||
function makeEditor() {
|
||||
const store = createTLStore({
|
||||
schema: spikeSchema,
|
||||
shapeUtils: spikeShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
})
|
||||
;(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable()
|
||||
|
||||
return new Editor({
|
||||
shapeUtils: spikeShapeUtils,
|
||||
bindingUtils: defaultBindingUtils,
|
||||
tools: [ExamMarkerBoxTool],
|
||||
store,
|
||||
getContainer: () => document.body,
|
||||
})
|
||||
}
|
||||
|
||||
describe('exam-marker spike tldraw helpers', () => {
|
||||
let editor: Editor
|
||||
|
||||
beforeEach(() => {
|
||||
editor = makeEditor()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
editor.dispose()
|
||||
})
|
||||
|
||||
it('creates a custom box shape and supports point hit-testing + page bounds', () => {
|
||||
const boxId = insertExamMarkerBox(editor)
|
||||
const box = editor.getShape(boxId)
|
||||
|
||||
expect(box?.type).toBe(EXAM_MARKER_BOX_TYPE)
|
||||
expect(editor.getShapePageBounds(boxId)).toBeTruthy()
|
||||
|
||||
const boxPoint = getExamMarkerHitTestPoints(editor, boxId)
|
||||
expect(boxPoint?.bounds).toBeTruthy()
|
||||
expect(editor.getShapesAtPoint(boxPoint!.center).some((shape) => shape.id === boxId)).toBe(true)
|
||||
|
||||
const viewport = editor.getViewportPageBounds()
|
||||
expect(viewport.w).toBeGreaterThan(0)
|
||||
expect(viewport.h).toBeGreaterThan(0)
|
||||
expect(() => editor.zoomToBounds(boxPoint!.bounds)).not.toThrow()
|
||||
})
|
||||
|
||||
it('registers the custom BaseBoxShapeTool and emits a locked svg asset URL', () => {
|
||||
expect(activateExamMarkerTool(editor)).toBe(EXAM_MARKER_BOX_TYPE)
|
||||
expect(editor.getCurrentToolId()).toBe(EXAM_MARKER_BOX_TYPE)
|
||||
|
||||
const svgUrl = buildExamMarkerSvgDataUrl()
|
||||
expect(svgUrl.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true)
|
||||
expect(decodeURIComponent(svgUrl.split(',')[1])).toContain('Exam scan')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
BaseBoxShapeTool,
|
||||
BaseBoxShapeUtil,
|
||||
Editor,
|
||||
HTMLContainer,
|
||||
T,
|
||||
TLBaseBoxShape,
|
||||
TLShapeId,
|
||||
createShapeId,
|
||||
toDomPrecision,
|
||||
} from '@tldraw/tldraw'
|
||||
|
||||
export const EXAM_MARKER_BOX_TYPE = 'exam-marker-box' as const
|
||||
|
||||
export type ExamMarkerBoxShape = TLBaseBoxShape & {
|
||||
type: typeof EXAM_MARKER_BOX_TYPE
|
||||
}
|
||||
|
||||
export class ExamMarkerBoxShapeUtil extends BaseBoxShapeUtil<ExamMarkerBoxShape> {
|
||||
static override type = EXAM_MARKER_BOX_TYPE
|
||||
static override props = {
|
||||
w: T.number,
|
||||
h: T.number,
|
||||
}
|
||||
|
||||
override getDefaultProps(): ExamMarkerBoxShape['props'] {
|
||||
return {
|
||||
w: 320,
|
||||
h: 180,
|
||||
}
|
||||
}
|
||||
|
||||
override component(shape: ExamMarkerBoxShape) {
|
||||
return (
|
||||
<HTMLContainer
|
||||
id={shape.id}
|
||||
style={{
|
||||
width: toDomPrecision(shape.props.w),
|
||||
height: toDomPrecision(shape.props.h),
|
||||
border: '2px solid #b91c1c',
|
||||
borderRadius: 14,
|
||||
background: 'linear-gradient(180deg, rgba(255, 251, 235, 0.96), rgba(254, 242, 242, 0.96))',
|
||||
boxShadow: '0 12px 24px rgba(185, 28, 28, 0.18)',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
color: '#7f1d1d',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
Exam marker spike
|
||||
</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.35 }}>
|
||||
This custom box shape uses the app's tldraw schema + BaseBoxShapeTool path.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start',
|
||||
background: 'rgba(185, 28, 28, 0.12)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Drag to resize · use the spike tool to create more
|
||||
</div>
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class ExamMarkerBoxTool extends BaseBoxShapeTool {
|
||||
static override id = EXAM_MARKER_BOX_TYPE
|
||||
static override initial = 'pointing'
|
||||
shapeType = EXAM_MARKER_BOX_TYPE
|
||||
}
|
||||
|
||||
export function buildExamMarkerSvgDataUrl(label = 'Exam scan') {
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="720" viewBox="0 0 960 720">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff"/>
|
||||
<stop offset="100%" stop-color="#f4f7fb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="960" height="720" rx="32" fill="url(#g)" stroke="#cbd5e1" stroke-width="6"/>
|
||||
<rect x="72" y="56" width="816" height="88" rx="18" fill="#1f2937" opacity="0.92"/>
|
||||
<text x="116" y="108" fill="#fff" font-size="42" font-family="Inter, Arial, sans-serif" font-weight="700">${label}</text>
|
||||
<rect x="72" y="176" width="816" height="120" rx="16" fill="#eef2ff" stroke="#c7d2fe"/>
|
||||
<text x="106" y="224" fill="#1e3a8a" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Question 1</text>
|
||||
<text x="106" y="266" fill="#334155" font-size="24" font-family="Inter, Arial, sans-serif">Show your working and annotate the image.</text>
|
||||
<rect x="72" y="332" width="816" height="250" rx="16" fill="#fff7ed" stroke="#fed7aa"/>
|
||||
<text x="106" y="384" fill="#9a3412" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Answer space</text>
|
||||
<rect x="106" y="420" width="700" height="18" rx="9" fill="#fdba74" opacity="0.75"/>
|
||||
<rect x="106" y="470" width="640" height="18" rx="9" fill="#fdba74" opacity="0.55"/>
|
||||
<rect x="106" y="520" width="570" height="18" rx="9" fill="#fdba74" opacity="0.4"/>
|
||||
<text x="106" y="644" fill="#475569" font-size="20" font-family="Inter, Arial, sans-serif">Inserted as a built-in tldraw image shape.</text>
|
||||
</svg>
|
||||
`.trim()
|
||||
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
|
||||
}
|
||||
|
||||
function getViewportAnchor(editor: Editor) {
|
||||
const bounds = editor.getViewportPageBounds()
|
||||
return {
|
||||
x: bounds.x + bounds.w / 2,
|
||||
y: bounds.y + bounds.h / 2,
|
||||
}
|
||||
}
|
||||
|
||||
export function insertExamMarkerImage(editor: Editor) {
|
||||
const anchor = getViewportAnchor(editor)
|
||||
const imageId = createShapeId()
|
||||
|
||||
editor.createShape({
|
||||
id: imageId,
|
||||
type: 'image',
|
||||
x: anchor.x - 320,
|
||||
y: anchor.y - 240,
|
||||
props: {
|
||||
url: buildExamMarkerSvgDataUrl(),
|
||||
w: 640,
|
||||
h: 480,
|
||||
name: 'exam-marker-sample.svg',
|
||||
},
|
||||
})
|
||||
|
||||
return imageId
|
||||
}
|
||||
|
||||
export function insertExamMarkerBox(editor: Editor) {
|
||||
const anchor = getViewportAnchor(editor)
|
||||
const boxId = createShapeId()
|
||||
|
||||
editor.createShape<ExamMarkerBoxShape>({
|
||||
id: boxId,
|
||||
type: EXAM_MARKER_BOX_TYPE,
|
||||
x: anchor.x + 180,
|
||||
y: anchor.y - 120,
|
||||
props: {
|
||||
w: 320,
|
||||
h: 180,
|
||||
},
|
||||
})
|
||||
|
||||
return boxId
|
||||
}
|
||||
|
||||
export function placeExamMarkerSpike(editor: Editor) {
|
||||
const imageId = insertExamMarkerImage(editor)
|
||||
const boxId = insertExamMarkerBox(editor)
|
||||
|
||||
const box = editor.getShape(boxId)
|
||||
if (box) {
|
||||
const bounds = editor.getShapePageBounds(box)
|
||||
if (bounds) editor.zoomToBounds(bounds)
|
||||
}
|
||||
|
||||
return { imageId, boxId }
|
||||
}
|
||||
|
||||
export function activateExamMarkerTool(editor: Editor) {
|
||||
editor.setCurrentTool(EXAM_MARKER_BOX_TYPE)
|
||||
return editor.getCurrentToolId()
|
||||
}
|
||||
|
||||
export function getExamMarkerHitTestPoints(editor: Editor, shapeId: TLShapeId) {
|
||||
const shape = editor.getShape(shapeId)
|
||||
if (!shape) return null
|
||||
|
||||
const bounds = editor.getShapePageBounds(shape)
|
||||
if (!bounds) return null
|
||||
|
||||
return {
|
||||
center: {
|
||||
x: bounds.x + bounds.w / 2,
|
||||
y: bounds.y + bounds.h / 2,
|
||||
},
|
||||
bounds,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user