Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eac792ced | ||
|
|
8e8a345e61 | ||
|
|
2de3e29179 | ||
|
|
29390d30ca | ||
|
|
aa2f35e467 | ||
|
|
61a189a7a2 | ||
|
|
ab6f0b09d7 | ||
|
|
16ae3aa089 | ||
|
|
496ec2cbf9 |
@@ -19,6 +19,9 @@ FROM nginx:alpine
|
|||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# .mjs files (pdfjs worker) must be served as application/javascript for module workers
|
||||||
|
RUN sed -i 's|application/javascript\s*js;|application/javascript js mjs;|' /etc/nginx/mime.types
|
||||||
|
|
||||||
# Create a simple nginx configuration
|
# Create a simple nginx configuration
|
||||||
RUN echo 'server { \
|
RUN echo 'server { \
|
||||||
listen 3000; \
|
listen 3000; \
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ vi.mock('./pages/NotFoundPublic', () => ({ default: () => <div>Public Not Found<
|
|||||||
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
|
vi.mock('./pages/tldraw/TLDrawCanvas', () => ({ default: () => <div>Public Home</div> }));
|
||||||
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
|
vi.mock('./pages/tldraw/singlePlayerPage', () => ({ default: () => <div>Single Player</div> }));
|
||||||
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
|
vi.mock('./pages/tldraw/multiplayerUser', () => ({ default: () => <div>Multiplayer</div> }));
|
||||||
vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div> }));
|
vi.mock('./pages/exam', () => ({
|
||||||
|
ExamDashboardPage: () => <div>Exam Marker</div>,
|
||||||
|
ExamTemplateSetupPage: () => <div>Exam Template Setup</div>,
|
||||||
|
}));
|
||||||
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
||||||
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||||
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
||||||
|
|||||||
@@ -6,28 +6,41 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
|||||||
import SaveIcon from '@mui/icons-material/Save'
|
import SaveIcon from '@mui/icons-material/Save'
|
||||||
import MouseIcon from '@mui/icons-material/Mouse'
|
import MouseIcon from '@mui/icons-material/Mouse'
|
||||||
import '@tldraw/tldraw/tldraw.css'
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
import { Editor, Tldraw, createShapeId, defaultBindingUtils, TLShape } from '@tldraw/tldraw'
|
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
import { ErrorBoundary } from '../../../components/ErrorBoundary'
|
||||||
import { logger } from '../../../debugConfig'
|
import { logger } from '../../../debugConfig'
|
||||||
import { examRepository } from '../../../services/exam/examRepository'
|
import { examRepository } from '../../../services/exam/examRepository'
|
||||||
import type { ExamTemplateDetail } from '../../../types/exam.types'
|
import type { ExamTemplateDetail } from '../../../types/exam.types'
|
||||||
import { ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||||
import { examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, shapeTypeToKind } from './examCanvasShapes'
|
import { PDF_PAGE_SHAPE_TYPE, canvasShapePalette, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
||||||
|
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
||||||
|
|
||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
{ id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'default' as const },
|
{ id: 'select', label: 'Select', icon: '↖', tip: 'Move, resize, delete, or inspect the Attached pill on a region.', color: 'inherit' 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.boundary, label: 'Boundary', icon: canvasShapePalette.boundary.icon, tip: 'Draw Q start and Q end horizontal rules; an end rule on a later page creates a multi-page question span.', 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.part, label: 'Part', icon: canvasShapePalette.part.icon, tip: 'Draw the markable sub-question box inside a boundary pair; it becomes the leaf question/part.', 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.response, label: 'Response', icon: canvasShapePalette.response.icon, tip: 'Draw around where the student writes; blue regions save as response areas.', 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.context, label: 'Context', icon: canvasShapePalette.context.icon, tip: 'Draw stimulus, figures, tables, or prompt text; purple dashed regions save as context.', 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.question_number, label: 'Q Number', icon: canvasShapePalette.question_number.icon, tip: 'Box the printed question number for OCR/structure extraction.', 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.mark_area, label: 'Mark Area', icon: canvasShapePalette.mark_area.icon, tip: 'Box printed marks such as [2] or Total for Question text.', color: 'success' as const },
|
||||||
{ id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const },
|
{ id: SHAPE_TYPES.reference, label: 'Reference', icon: canvasShapePalette.reference.icon, tip: 'Box formulae, data sheets, appendices, or other resources the student may use.', color: 'info' as const },
|
||||||
{ id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'default' as const },
|
{ id: SHAPE_TYPES.furniture, label: 'Furniture', icon: canvasShapePalette.furniture.icon, tip: 'Mark page numbers, margins, blank space, or decoration to exclude from extraction.', color: 'inherit' as const },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const PAGE_START_X = 260
|
||||||
|
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
|
||||||
|
|
||||||
|
function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
|
||||||
|
let y = 0
|
||||||
|
return pages.map((page) => {
|
||||||
|
const geometry = { pageNumber: page.pageNumber, x: PAGE_START_X, y, w: page.width, h: page.height }
|
||||||
|
y += page.height
|
||||||
|
return geometry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
function apiMessage(err: unknown): { message: string; conflict: boolean } {
|
||||||
if (axios.isAxiosError(err)) {
|
if (axios.isAxiosError(err)) {
|
||||||
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
const detail = (err.response?.data as { detail?: string } | undefined)?.detail
|
||||||
@@ -88,14 +101,35 @@ function loadShapes(editor: Editor, models: ExamCanvasShapeModel[]) {
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
|
||||||
|
const existing = editor.getCurrentPageShapes().filter((s) => isPdfPageShape(s.type)).map((s) => s.id)
|
||||||
|
if (existing.length) editor.deleteShapes(existing)
|
||||||
|
if (!pages.length) return
|
||||||
|
const geometries = pageGeometryFromImages(pages)
|
||||||
|
editor.createShapes(geometries.map((geometry) => {
|
||||||
|
const page = pages[geometry.pageNumber - 1]
|
||||||
|
return {
|
||||||
|
id: createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber),
|
||||||
|
type: PDF_PAGE_SHAPE_TYPE,
|
||||||
|
x: geometry.x,
|
||||||
|
y: geometry.y,
|
||||||
|
isLocked: true,
|
||||||
|
props: { w: geometry.w, h: geometry.h, src: page.src, pageNumber: geometry.pageNumber },
|
||||||
|
} as any
|
||||||
|
}))
|
||||||
|
const ids = geometries.map((geometry) => createShapeId(PDF_PAGE_IDS_PREFIX + geometry.pageNumber))
|
||||||
|
try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ }
|
||||||
|
}
|
||||||
|
|
||||||
function seedGuide(editor: Editor) {
|
function seedGuide(editor: Editor) {
|
||||||
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
|
const current = editor.getCurrentPageShapes().filter((s) => shapeTypeToKind(s.type))
|
||||||
if (current.length) return
|
if (current.length) return
|
||||||
editor.createShapes([
|
editor.createShapes([
|
||||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } },
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 120, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 start', domainId: newDomainId() } },
|
||||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: 520, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end', domainId: newDomainId() } },
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.boundary, x: 48, y: PAGE_HEIGHT + 160, props: { w: PAGE_WIDTH - 96, h: 8, kind: 'boundary', label: 'Q1 end (page 2)', domainId: newDomainId() } },
|
||||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } },
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.part, x: 92, y: 180, props: { w: 520, h: 150, kind: 'part', label: 'Q1(a)', maxMarks: 3, domainId: newDomainId() } },
|
||||||
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } },
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.response, x: 116, y: 355, props: { w: 470, h: 120, kind: 'response', label: 'Response', responseForm: 'lines', domainId: newDomainId() } },
|
||||||
|
{ id: createShapeId(newDomainId()), type: SHAPE_TYPES.context, x: 116, y: 495, props: { w: 470, h: 90, kind: 'context', label: 'Context', contextType: 'generic', domainId: newDomainId() } },
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +138,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const editorRef = useRef<Editor | null>(null)
|
const editorRef = useRef<Editor | null>(null)
|
||||||
|
const pageGeometriesRef = useRef<CanvasPageGeometry[]>([])
|
||||||
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
const [template, setTemplate] = useState<ExamTemplateDetail | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -111,6 +146,8 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [conflict, setConflict] = useState<string | null>(null)
|
const [conflict, setConflict] = useState<string | null>(null)
|
||||||
const [activeTool, setActiveTool] = useState('select')
|
const [activeTool, setActiveTool] = useState('select')
|
||||||
|
const [pdfStatus, setPdfStatus] = useState<'loading' | 'ready' | 'missing' | 'error'>('loading')
|
||||||
|
const [pdfError, setPdfError] = useState<string | null>(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
@@ -118,8 +155,40 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const detail = await examRepository.getTemplate(templateId)
|
const detail = await examRepository.getTemplate(templateId)
|
||||||
setTemplate(detail)
|
setTemplate(detail)
|
||||||
|
let pages: PdfPageImage[] = []
|
||||||
|
setPdfStatus('loading')
|
||||||
|
setPdfError(null)
|
||||||
|
try {
|
||||||
|
const bytes = await examRepository.getTemplateSourcePdf(templateId)
|
||||||
|
pages = await loadPdfPageImages(bytes, undefined, (partialPages) => {
|
||||||
|
const newPage = partialPages[partialPages.length - 1]
|
||||||
|
const allGeometries = pageGeometryFromImages(partialPages)
|
||||||
|
pageGeometriesRef.current = allGeometries
|
||||||
|
const ed = editorRef.current
|
||||||
|
if (ed) {
|
||||||
|
const geometry = allGeometries[partialPages.length - 1]
|
||||||
|
const shapeId = createShapeId(PDF_PAGE_IDS_PREFIX + newPage.pageNumber)
|
||||||
|
if (!ed.getCurrentPageShapes().find((s) => s.id === shapeId)) {
|
||||||
|
ed.createShapes([{ id: shapeId, type: PDF_PAGE_SHAPE_TYPE, x: geometry.x, y: geometry.y, isLocked: true, props: { w: geometry.w, h: geometry.h, src: newPage.src, pageNumber: newPage.pageNumber } } as any])
|
||||||
|
try { ed.sendToBack([shapeId as any]) } catch { /* */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setPdfStatus('ready')
|
||||||
|
})
|
||||||
|
setPdfStatus(pages.length ? 'ready' : 'missing')
|
||||||
|
} catch (pdfErr) {
|
||||||
|
const pdfMsg = apiMessage(pdfErr).message
|
||||||
|
setPdfStatus(pdfMsg.toLowerCase().includes('404') ? 'missing' : 'error')
|
||||||
|
setPdfError(pdfMsg)
|
||||||
|
logger.warn('cc-exam-marker', 'Template source PDF load failed', { templateId, message: pdfMsg })
|
||||||
|
}
|
||||||
|
const geometries = pageGeometryFromImages(pages)
|
||||||
|
pageGeometriesRef.current = geometries
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
if (editor) loadShapes(editor, shapesFromTemplate(detail))
|
if (editor) {
|
||||||
|
syncPdfPages(editor, pages)
|
||||||
|
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
||||||
|
}
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = apiMessage(e).message
|
const msg = apiMessage(e).message
|
||||||
@@ -139,10 +208,10 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
ensureDomainIds(editor)
|
ensureDomainIds(editor)
|
||||||
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
const shapes = editor.getCurrentPageShapes().map(modelFromTLShape).filter(Boolean) as ExamCanvasShapeModel[]
|
||||||
const payload = serializeCanvasShapes(template, shapes)
|
const payload = serializeCanvasShapes(template, shapes, pageGeometriesRef.current)
|
||||||
const saved = await examRepository.replaceTemplate(templateId, payload)
|
const saved = await examRepository.replaceTemplate(templateId, payload)
|
||||||
setTemplate(saved)
|
setTemplate(saved)
|
||||||
loadShapes(editor, shapesFromTemplate(saved))
|
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = apiMessage(e)
|
const msg = apiMessage(e)
|
||||||
@@ -159,7 +228,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
variant={activeTool === tool.id ? 'contained' : 'outlined'}
|
variant={activeTool === tool.id ? 'contained' : 'outlined'}
|
||||||
color={tool.color}
|
color={tool.color}
|
||||||
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : undefined}
|
startIcon={tool.id === 'select' ? <MouseIcon fontSize="small" /> : <Box component="span" sx={{ minWidth: 22, textAlign: 'center', fontWeight: 900 }}>{tool.icon}</Box>}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
@@ -178,16 +247,15 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
|
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
|
||||||
<Tldraw
|
<Tldraw
|
||||||
shapeUtils={examCanvasShapeUtils as any}
|
shapeUtils={examCanvasShapeUtils as any}
|
||||||
bindingUtils={defaultBindingUtils}
|
|
||||||
tools={examCanvasTools as any}
|
tools={examCanvasTools as any}
|
||||||
hideUi
|
hideUi
|
||||||
inferDarkMode={theme.palette.mode === 'dark'}
|
inferDarkMode={theme.palette.mode === 'dark'}
|
||||||
autoFocus
|
autoFocus
|
||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
editorRef.current = editor
|
editorRef.current = editor
|
||||||
editor.user.updateUserPreferences({ isDarkMode: theme.palette.mode === 'dark' })
|
editor.user.updateUserPreferences({ colorScheme: theme.palette.mode === 'dark' ? 'dark' : 'light' })
|
||||||
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
editor.store.listen(() => setDirty(true), { scope: 'document' })
|
||||||
if (template) loadShapes(editor, shapesFromTemplate(template)); else seedGuide(editor)
|
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -197,7 +265,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
<Divider orientation="vertical" flexItem />
|
<Divider orientation="vertical" flexItem />
|
||||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
|
<Typography variant="subtitle1" noWrap>{template?.title ?? 'Template setup'}</Typography>
|
||||||
<Typography variant="caption" color="text.secondary">Exam Marker › Setup · draw boundaries, part boxes, and regions; Save persists a full replace.</Typography>
|
<Typography variant="caption" color="text.secondary">Exam Marker › Setup · coloured tools map to persisted regions; boundary start/end pairs can span pages.</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Chip size="small" color={dirty ? 'warning' : 'success'} label={dirty ? 'Unsaved' : 'Saved'} />
|
<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>
|
<Button variant="contained" startIcon={saving ? <CircularProgress size={16} color="inherit" /> : <SaveIcon />} onClick={save} disabled={saving || loading || !template}>Save</Button>
|
||||||
@@ -207,10 +275,23 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
<Stack spacing={1}>{toolButtons}</Stack>
|
<Stack spacing={1}>{toolButtons}</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 420, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
<Paper elevation={4} sx={{ position: 'absolute', right: 16, bottom: 16, maxWidth: 460, p: 2, borderRadius: 3, bgcolor: 'background.paper' }}>
|
||||||
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
<Typography variant="subtitle2" gutterBottom>Setup guide</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
1) Draw top and bottom Boundary lines for each main question. 2) Draw Part boxes inside the pair. 3) Draw Response/Context/metadata regions inside a Part. Save derives parent links by spatial containment and reloads from the API.
|
1) Boundary start/end lines define each main question. 2) Draw amber Part boxes for markable sub-questions. 3) Draw coloured Response, Context, Q Number, Mark Area, Reference, and Furniture regions; Save derives parent links by containment.
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={0.75} useFlexGap flexWrap="wrap" sx={{ my: 1 }}>
|
||||||
|
{(['boundary', 'part', 'response', 'context', 'question_number', 'mark_area', 'reference', 'furniture'] as const).map((kind) => {
|
||||||
|
const p = canvasShapePalette[kind]
|
||||||
|
return <Chip key={kind} size="small" label={`${p.icon} ${p.label}`} sx={{ borderColor: p.stroke, color: p.stroke, bgcolor: p.fill, fontWeight: 700 }} variant="outlined" />
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
<Divider sx={{ my: 1 }} />
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block">Multi-page boundary pairing</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>Draw “Q start” on page N, then “Q end” on a later page; save pairs boundaries by reading order into one question span.</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.75 }}>Open design choices resolved for v1: labels use “Q start/end”; persistent Attached pills confirm containment; rectangles stay simple for dense multi-column papers; Back button remains explicit.</Typography>
|
||||||
|
<Typography variant="caption" color={pdfStatus === 'ready' ? 'success.main' : pdfStatus === 'error' ? 'error.main' : 'text.secondary'} sx={{ display: 'block', mt: 1 }}>
|
||||||
|
PDF backdrop: {pdfStatus === 'ready' ? 'loaded and locked behind regions' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF for this template' : pdfError ?? 'failed to load'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import React from 'react'
|
|||||||
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
|
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
|
||||||
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
||||||
|
|
||||||
|
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||||
|
|
||||||
export const SHAPE_TYPES = {
|
export const SHAPE_TYPES = {
|
||||||
boundary: 'exam-boundary',
|
boundary: 'exam-boundary',
|
||||||
part: 'exam-part',
|
part: 'exam-part',
|
||||||
@@ -14,6 +16,11 @@ export const SHAPE_TYPES = {
|
|||||||
furniture: 'exam-region-furniture',
|
furniture: 'exam-region-furniture',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
export type ExamPdfPageTLShape = TLBaseBoxShape & {
|
||||||
|
type: typeof PDF_PAGE_SHAPE_TYPE
|
||||||
|
props: { w: number; h: number; src: string; pageNumber: number }
|
||||||
|
}
|
||||||
|
|
||||||
export type ExamCanvasTLShape = TLBaseBoxShape & {
|
export type ExamCanvasTLShape = TLBaseBoxShape & {
|
||||||
type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
|
type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES]
|
||||||
props: {
|
props: {
|
||||||
@@ -29,47 +36,94 @@ export type ExamCanvasTLShape = TLBaseBoxShape & {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: string }> = {
|
type CanvasPaletteEntry = {
|
||||||
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' },
|
stroke: string
|
||||||
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' },
|
fill: string
|
||||||
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' },
|
darkStroke: string
|
||||||
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' },
|
darkFill: string
|
||||||
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' },
|
dash?: string
|
||||||
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' },
|
label: string
|
||||||
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' },
|
icon: string
|
||||||
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' },
|
role: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const canvasShapePalette: Record<ExamCanvasShapeKind, CanvasPaletteEntry> = {
|
||||||
|
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', darkStroke: '#f87171', darkFill: 'rgba(248,113,113,0.10)', dash: '8 6', label: 'Boundary', icon: '↕', role: 'start/end rule' },
|
||||||
|
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.18)', darkStroke: '#fbbf24', darkFill: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', role: 'markable box' },
|
||||||
|
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.18)', darkStroke: '#60a5fa', darkFill: 'rgba(96,165,250,0.34)', label: 'Response', icon: '✎', role: 'student writing' },
|
||||||
|
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', darkStroke: '#a78bfa', darkFill: 'rgba(167,139,250,0.28)', dash: '6 5', label: 'Context', icon: '◉', role: 'stimulus' },
|
||||||
|
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', darkStroke: '#2dd4bf', darkFill: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', role: 'printed label' },
|
||||||
|
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', darkStroke: '#4ade80', darkFill: 'rgba(74,222,128,0.23)', label: 'Marks', icon: '[2]', role: 'printed marks' },
|
||||||
|
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', darkStroke: '#22d3ee', darkFill: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '§', role: 'resource' },
|
||||||
|
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', darkStroke: '#cbd5e1', darkFill: 'rgba(148,163,184,0.18)', dash: '3 5', label: 'Furniture', icon: '×', role: 'ignore' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const shapeCss = `
|
||||||
|
.exam-canvas-shape { --exam-stroke: var(--exam-light-stroke); --exam-fill: var(--exam-light-fill); }
|
||||||
|
[data-color-mode="dark"] .exam-canvas-shape, .tl-theme__dark .exam-canvas-shape { --exam-stroke: var(--exam-dark-stroke); --exam-fill: var(--exam-dark-fill); }
|
||||||
|
.exam-canvas-shape__pill { background: rgba(255,255,255,.90); color: var(--exam-stroke); box-shadow: 0 1px 4px rgba(15,23,42,.14); }
|
||||||
|
[data-color-mode="dark"] .exam-canvas-shape__pill, .tl-theme__dark .exam-canvas-shape__pill { background: rgba(15,23,42,.88); color: var(--exam-stroke); box-shadow: 0 1px 5px rgba(0,0,0,.35); }
|
||||||
|
`
|
||||||
|
|
||||||
function renderShape(shape: ExamCanvasTLShape) {
|
function renderShape(shape: ExamCanvasTLShape) {
|
||||||
const kind = shape.props.kind
|
const kind = shape.props.kind
|
||||||
const p = palette[kind] ?? palette.response
|
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
||||||
const isBoundary = kind === 'boundary'
|
const isBoundary = kind === 'boundary'
|
||||||
return (
|
return (
|
||||||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||||||
<div style={{
|
<style>{shapeCss}</style>
|
||||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid ${p.stroke}`,
|
<div
|
||||||
|
className={`exam-canvas-shape exam-canvas-shape--${kind}`}
|
||||||
|
style={{
|
||||||
|
'--exam-light-stroke': p.stroke,
|
||||||
|
'--exam-light-fill': p.fill,
|
||||||
|
'--exam-dark-stroke': p.darkStroke,
|
||||||
|
'--exam-dark-fill': p.darkFill,
|
||||||
|
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid var(--exam-stroke)`,
|
||||||
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
||||||
background: isBoundary ? 'transparent' : p.fill, color: p.stroke, fontFamily: 'Inter, system-ui, sans-serif',
|
background: isBoundary ? 'transparent' : 'var(--exam-fill)', color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
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'
|
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? '0 0 0 3px rgba(239,68,68,0.08)' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
||||||
}}>
|
} as React.CSSProperties}
|
||||||
<span style={{ fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.6, background: 'rgba(255,255,255,0.84)', borderRadius: 999, padding: '2px 7px' }}>
|
aria-label={`${p.label}: ${p.role}`}
|
||||||
|
title={`${p.label}: ${p.role}`}
|
||||||
|
>
|
||||||
|
<span className="exam-canvas-shape__pill" style={{ fontSize: 12, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span aria-hidden="true">{p.icon}</span>
|
||||||
{shape.props.label || p.label}
|
{shape.props.label || p.label}
|
||||||
</span>
|
</span>
|
||||||
{!isBoundary && shape.props.questionId && <span style={{ fontSize: 11, fontWeight: 700, opacity: .75 }}>Attached</span>}
|
{!isBoundary && shape.props.questionId && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
|
||||||
|
{isBoundary && <span className="exam-canvas-shape__pill" style={{ fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px' }}>pair across pages</span>}
|
||||||
</div>
|
</div>
|
||||||
</HTMLContainer>
|
</HTMLContainer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) {
|
||||||
const p = palette[kind]
|
const p = canvasShapePalette[kind]
|
||||||
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
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, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) }; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) } }
|
const sharedProps = { w: T.number, h: T.number, label: T.string, kind: T.string, maxMarks: T.optional(T.number), responseForm: T.optional(T.string), contextType: T.optional(T.string), questionId: T.optional(T.string), domainId: T.optional(T.string) }
|
||||||
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) } }
|
const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
||||||
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 PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
||||||
|
static override type = PDF_PAGE_SHAPE_TYPE
|
||||||
|
static override props = { w: T.number, h: T.number, src: T.string, pageNumber: T.number }
|
||||||
|
override getDefaultProps() { return { w: 780, h: 1100, src: '', pageNumber: 1 } }
|
||||||
|
override canEdit() { return false }
|
||||||
|
override component(shape: ExamPdfPageTLShape) {
|
||||||
|
return (
|
||||||
|
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'none' }}>
|
||||||
|
<img src={shape.props.src} alt={'PDF page ' + shape.props.pageNumber} draggable={false} style={{ width: '100%', height: '100%', display: 'block', userSelect: 'none', pointerEvents: 'none', boxShadow: '0 2px 16px rgba(15,23,42,0.18)', background: '#fff' }} />
|
||||||
|
</HTMLContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
||||||
|
}
|
||||||
|
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||||
|
class PartUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.part; static override props = sharedProps; override getDefaultProps(){ return defaultProps('part', 420, 170) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||||
|
function regionUtil(type: string, kind: ExamCanvasRegionKind, w = 360, h = 120) { return class extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = type; static override props = sharedProps; override getDefaultProps(){ return defaultProps(kind, w, h) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } } }
|
||||||
|
|
||||||
class BoundaryTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.boundary; static override initial = 'pointing'; shapeType = SHAPE_TYPES.boundary }
|
class 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 PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part }
|
||||||
@@ -80,9 +134,13 @@ class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.m
|
|||||||
class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference }
|
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 }
|
class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture }
|
||||||
|
|
||||||
export const examCanvasShapeUtils = [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 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 const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const
|
||||||
|
|
||||||
|
export function isPdfPageShape(type: string): boolean {
|
||||||
|
return type === PDF_PAGE_SHAPE_TYPE
|
||||||
|
}
|
||||||
|
|
||||||
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
|
export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null {
|
||||||
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
|
const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type)
|
||||||
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
|
return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as pdfjsLib from "pdfjs-dist"
|
||||||
|
import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url"
|
||||||
|
|
||||||
|
import { PAGE_WIDTH } from "../../../utils/exam-canvas/model"
|
||||||
|
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerSrc
|
||||||
|
|
||||||
|
export interface PdfPageImage {
|
||||||
|
pageNumber: number
|
||||||
|
src: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPdfPageImages(
|
||||||
|
pdfBytes: ArrayBuffer,
|
||||||
|
targetWidth = PAGE_WIDTH,
|
||||||
|
onPageReady?: (pages: PdfPageImage[]) => void,
|
||||||
|
): Promise<PdfPageImage[]> {
|
||||||
|
const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(pdfBytes) }).promise
|
||||||
|
const pages: PdfPageImage[] = []
|
||||||
|
// Reuse a single canvas across all pages to avoid allocating ~120 MB of canvas memory
|
||||||
|
// for a typical 36-page exam paper.
|
||||||
|
const canvas = document.createElement("canvas")
|
||||||
|
|
||||||
|
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
|
||||||
|
const page = await pdf.getPage(pageNumber)
|
||||||
|
const baseViewport = page.getViewport({ scale: 1 })
|
||||||
|
const scale = targetWidth / baseViewport.width
|
||||||
|
const viewport = page.getViewport({ scale })
|
||||||
|
canvas.width = Math.ceil(viewport.width)
|
||||||
|
canvas.height = Math.ceil(viewport.height)
|
||||||
|
const context = canvas.getContext("2d")
|
||||||
|
if (!context) throw new Error("Unable to create PDF render canvas")
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
await page.render({ canvasContext: context, viewport }).promise
|
||||||
|
pages.push({
|
||||||
|
pageNumber,
|
||||||
|
src: canvas.toDataURL("image/png"),
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
})
|
||||||
|
onPageReady?.([...pages])
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages
|
||||||
|
}
|
||||||
@@ -127,6 +127,15 @@ export const examRepository = {
|
|||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getTemplateSourcePdf(templateId: string): Promise<ArrayBuffer> {
|
||||||
|
const headers = await authHeaders();
|
||||||
|
const res = await axios.get<ArrayBuffer>(`${EXAM_BASE}/templates/${templateId}/source-pdf`, {
|
||||||
|
headers,
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
|
||||||
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||||
const headers = await authHeaders();
|
const headers = await authHeaders();
|
||||||
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import type { ExamTemplateDetail } from '../../types/exam.types'
|
import type { ExamTemplateDetail } from '../../types/exam.types'
|
||||||
import { isUuid, serializeCanvasShapes, shapesFromTemplate } from './model'
|
import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './model'
|
||||||
|
|
||||||
const template: ExamTemplateDetail = {
|
const template: ExamTemplateDetail = {
|
||||||
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
||||||
@@ -30,6 +29,23 @@ describe('exam setup canvas serialization', () => {
|
|||||||
expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true)
|
expect(payload.boundaries.every((b) => isUuid(b.id))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('maps shapes to the visible PDF page geometry rather than a fixed page height', () => {
|
||||||
|
const pages = [
|
||||||
|
{ pageNumber: 1, x: 260, y: 0, w: 780, h: 1000 },
|
||||||
|
{ pageNumber: 2, x: 260, y: 1000, w: 780, h: 1200 },
|
||||||
|
]
|
||||||
|
expect(pageForY(1050, pages)).toBe(2)
|
||||||
|
const payload = serializeCanvasShapes(template, [
|
||||||
|
{ id: 'b-top', kind: 'boundary', x: 260, y: 1020, w: 700, h: 8, label: 'Q1 start' },
|
||||||
|
{ id: 'b-bottom', kind: 'boundary', x: 260, y: 1700, w: 700, h: 8, label: 'Q1 end' },
|
||||||
|
{ id: 'part-1', kind: 'part', x: 300, y: 1120, w: 300, h: 160, label: 'Q1(a)' },
|
||||||
|
{ id: 'resp-1', kind: 'response', x: 320, y: 1160, w: 240, h: 80 },
|
||||||
|
], pages)
|
||||||
|
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
||||||
|
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
||||||
|
expect(payload.response_areas[0].page).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
|
it('round-trips saved Part geometry and all S4-9 region kinds from API detail', () => {
|
||||||
const shapes = shapesFromTemplate({
|
const shapes = shapesFromTemplate({
|
||||||
...template,
|
...template,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import type { ExamTemplateDetail, TemplateReplacePayload } from '../../types/exa
|
|||||||
|
|
||||||
export const PAGE_HEIGHT = 1100
|
export const PAGE_HEIGHT = 1100
|
||||||
export const PAGE_WIDTH = 780
|
export const PAGE_WIDTH = 780
|
||||||
|
export const PAGE_GAP = 0
|
||||||
|
|
||||||
|
export interface CanvasPageGeometry { pageNumber: number; x: number; y: number; w: number; h: number }
|
||||||
|
|
||||||
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
||||||
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
||||||
@@ -25,10 +28,28 @@ export interface ExamCanvasShapeModel {
|
|||||||
questionId?: string | null
|
questionId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pageForY(y: number): number {
|
export function pageForY(y: number, pages?: CanvasPageGeometry[]): number {
|
||||||
|
if (pages?.length) {
|
||||||
|
const hit = pages.find((page) => y >= page.y && y <= page.y + page.h)
|
||||||
|
if (hit) return hit.pageNumber
|
||||||
|
const nearest = pages.reduce((best, page) => {
|
||||||
|
const dy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.h)))
|
||||||
|
return dy < best.dy ? { page, dy } : best
|
||||||
|
}, { page: pages[0], dy: Number.POSITIVE_INFINITY })
|
||||||
|
return nearest.page.pageNumber
|
||||||
|
}
|
||||||
return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
|
return Math.max(1, Math.floor(y / PAGE_HEIGHT) + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function pageTop(page: number, pages?: CanvasPageGeometry[]): number {
|
||||||
|
const hit = pages?.find((p) => p.pageNumber === page)
|
||||||
|
return hit?.y ?? ((page - 1) * (PAGE_HEIGHT + PAGE_GAP))
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageForShape(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): number {
|
||||||
|
return pageForY(shape.y + shape.h / 2, pages)
|
||||||
|
}
|
||||||
|
|
||||||
export function isUuid(value: string | null | undefined): value is string {
|
export function isUuid(value: string | null | undefined): value is string {
|
||||||
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||||
}
|
}
|
||||||
@@ -63,10 +84,10 @@ function bandContains(top: ExamCanvasShapeModel, bottom: ExamCanvasShapeModel, s
|
|||||||
return cy >= minY && cy <= maxY
|
return cy >= minY && cy <= maxY
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[]): TemplateReplacePayload {
|
export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: ExamCanvasShapeModel[], pages?: CanvasPageGeometry[]): TemplateReplacePayload {
|
||||||
const orderedBoundaries = shapes
|
const orderedBoundaries = shapes
|
||||||
.filter((s) => s.kind === 'boundary')
|
.filter((s) => s.kind === 'boundary')
|
||||||
.sort((a, b) => (pageForY(a.y) - pageForY(b.y)) || (a.y - b.y))
|
.sort((a, b) => (pageForShape(a, pages) - pageForShape(b, pages)) || (a.y - b.y))
|
||||||
const parts = shapes.filter((s) => s.kind === 'part')
|
const parts = shapes.filter((s) => s.kind === 'part')
|
||||||
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
|
const regions = shapes.filter((s) => s.kind !== 'boundary' && s.kind !== 'part')
|
||||||
|
|
||||||
@@ -84,7 +105,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
|
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
|
||||||
bands.push({ questionId, top, bottom })
|
bands.push({ questionId, top, bottom })
|
||||||
for (const b of [top, bottom]) {
|
for (const b of [top, bottom]) {
|
||||||
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForY(b.y) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
|
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,23 +114,23 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
||||||
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
|
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
|
||||||
partQuestionIds.set(part.id, qid)
|
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: pageForY(part.y) })
|
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages) })
|
||||||
})
|
})
|
||||||
|
|
||||||
const response_areas: TemplateReplacePayload['response_areas'] = []
|
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||||
for (const region of regions) {
|
for (const region of regions) {
|
||||||
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
const containingPart = parts.find((part) => contains(bounds(part), bounds(region)))
|
||||||
const fallbackPart = parts.find((part) => pageForY(part.y) === pageForY(region.y)) ?? parts[0]
|
const fallbackPart = parts.find((part) => pageForShape(part, pages) === pageForShape(region, pages)) ?? parts[0]
|
||||||
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
||||||
if (!questionId) continue
|
if (!questionId) continue
|
||||||
const kind = region.kind as ExamCanvasRegionKind
|
const kind = region.kind as ExamCanvasRegionKind
|
||||||
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForY(region.y), 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 })
|
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
|
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeModel[] {
|
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
||||||
const shapes: ExamCanvasShapeModel[] = []
|
const shapes: ExamCanvasShapeModel[] = []
|
||||||
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
||||||
for (const b of detail.boundaries ?? []) {
|
for (const b of detail.boundaries ?? []) {
|
||||||
@@ -121,9 +142,9 @@ export function shapesFromTemplate(detail: ExamTemplateDetail): ExamCanvasShapeM
|
|||||||
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
|
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
|
||||||
}
|
}
|
||||||
for (const r of detail.response_areas ?? []) {
|
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 bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
|
||||||
const q = questions.get(r.question_id)
|
const q = questions.get(r.question_id)
|
||||||
shapes.push({ id: 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 })
|
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
|
||||||
}
|
}
|
||||||
return shapes
|
return shapes
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user