@@ -1,8 +1,9 @@
import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react'
import React , { useCallback , useEffect , useMemo , useRef , useState } from 'react'
import { useNavigate , useParams } from 'react-router-dom'
import { useNavigate , useParams } from 'react-router-dom'
import { Alert , Box , Button , Chip , CircularProgress , Divider , Paper , Snackbar , Stack , Tooltip , Typography , useTheme } from '@mui/material'
import { Alert , Box , Button , Chip , CircularProgress , Collapse , Divider , IconButton , Paper , Snackbar , Stack , Tooltip , Typography , useTheme } from '@mui/material'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
import HelpOutlineIcon from '@mui/icons-material/HelpOutline'
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'
@@ -29,7 +30,7 @@ const TOOLS = [
{ 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 } ,
{ 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 PAGE_START_X = 0
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
const PDF_PAGE_IDS_PREFIX = 'pdf-page-'
function pageGeometryFromImages ( pages : PdfPageImage [ ] ) : CanvasPageGeometry [ ] {
function pageGeometryFromImages ( pages : PdfPageImage [ ] ) : CanvasPageGeometry [ ] {
@@ -41,6 +42,22 @@ function pageGeometryFromImages(pages: PdfPageImage[]): CanvasPageGeometry[] {
} )
} )
}
}
function applyDocViewConstraints ( editor : Editor , pages : PdfPageImage [ ] ) {
const maxW = pages . length ? Math . max ( . . . pages . map ( ( p ) = > p . width ) ) : PAGE_WIDTH
const totalH = pages . reduce ( ( sum , p ) = > sum + p . height , 0 ) || PAGE_HEIGHT
editor . setCameraOptions ( {
constraints : {
bounds : { x : - 64 , y : - 64 , w : maxW + 128 , h : totalH + 128 } ,
padding : { x : 64 , y : 64 } ,
origin : { x : 0.5 , y : 0 } ,
initialZoom : 'fit-x-100' ,
baseZoom : 'default' ,
behavior : 'contain' ,
} ,
isLocked : false ,
} )
}
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
@@ -152,6 +169,7 @@ const ExamTemplateSetupInner: React.FC = () => {
const [ activeTool , setActiveTool ] = useState ( 'select' )
const [ activeTool , setActiveTool ] = useState ( 'select' )
const [ pdfStatus , setPdfStatus ] = useState < 'loading' | 'ready' | 'missing' | 'error' > ( 'loading' )
const [ pdfStatus , setPdfStatus ] = useState < 'loading' | 'ready' | 'missing' | 'error' > ( 'loading' )
const [ pdfError , setPdfError ] = useState < string | null > ( null )
const [ pdfError , setPdfError ] = useState < string | null > ( null )
const [ guideOpen , setGuideOpen ] = useState ( false )
const load = useCallback ( async ( ) = > {
const load = useCallback ( async ( ) = > {
if ( ! templateId ) return
if ( ! templateId ) return
@@ -176,6 +194,7 @@ const ExamTemplateSetupInner: React.FC = () => {
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 ] )
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 ] )
bringDomainShapesToFront ( ed )
bringDomainShapesToFront ( ed )
}
}
applyDocViewConstraints ( ed , partialPages )
}
}
setPdfStatus ( 'ready' )
setPdfStatus ( 'ready' )
} )
} )
@@ -193,6 +212,8 @@ const ExamTemplateSetupInner: React.FC = () => {
syncPdfPages ( editor , pages )
syncPdfPages ( editor , pages )
loadShapes ( editor , shapesFromTemplate ( detail , geometries ) )
loadShapes ( editor , shapesFromTemplate ( detail , geometries ) )
bringDomainShapesToFront ( editor )
bringDomainShapesToFront ( editor )
applyDocViewConstraints ( editor , pages )
editor . resetZoom ( )
}
}
setDirty ( false )
setDirty ( false )
} catch ( e ) {
} catch ( e ) {
@@ -248,8 +269,30 @@ const ExamTemplateSetupInner: React.FC = () => {
) ) , [ activeTool ] )
) ) , [ activeTool ] )
return (
return (
< Box sx = { { position : 'fixed' , inset : 0 , zIndex : ( t ) = > t . zIndex . drawer + 20 , bgcolor : 'background.default' } } >
< Box sx = { { position : 'fixed' , inset : 0 , zIndex : ( t ) = > t . zIndex . drawer + 20 , bgcolor : 'background.default' , display : 'flex' , flexDirection : 'column' } } >
< Box sx = { { position : 'absolute' , inset : 0 , '& .tlui-layout' : { display : 'none' } } } data-testid = "exam-template-setup-canvas" >
{ /* Top bar — single compact line */ }
< Paper elevation = { 8 } sx = { { px : 1.5 , py : 0.75 , display : 'flex' , alignItems : 'center' , gap : 1 , bgcolor : 'background.paper' , borderRadius : 0 , flexShrink : 0 } } >
< Tooltip title = "Back to exam marker" >
< IconButton onClick = { ( ) = > navigate ( '/exam-marker' ) } size = "small" > < ArrowBackIcon fontSize = "small" / > < / IconButton >
< / Tooltip >
< Divider orientation = "vertical" flexItem / >
< Typography variant = "subtitle2" noWrap sx = { { flex : 1 , minWidth : 0 } } > { template ? . title ? ? 'Template setup' } < / Typography >
< Chip size = "small" color = { dirty ? 'warning' : 'success' } label = { dirty ? 'Unsaved' : 'Saved' } / >
< Button size = "small" variant = "contained" startIcon = { saving ? < CircularProgress size = { 14 } color = "inherit" / > : < SaveIcon fontSize = "small" / > } onClick = { save } disabled = { saving || loading || ! template } > Save < / Button >
< / Paper >
{ /* Body row */ }
< Box sx = { { flex : 1 , display : 'flex' , overflow : 'hidden' } } >
{ /* Left tool sidebar */ }
< Paper elevation = { 4 } sx = { { width : 160 , flexShrink : 0 , p : 1.25 , borderRadius : 0 , bgcolor : 'background.paper' , overflowY : 'auto' , display : 'flex' , flexDirection : 'column' , borderRight : 1 , borderColor : 'divider' } } >
< Stack spacing = { 1 } > { toolButtons } < / Stack >
< / Paper >
{ /* Canvas area */ }
< Box sx = { { flex : 1 , position : 'relative' , overflow : 'hidden' } } data-testid = "exam-template-setup-canvas" >
< Box sx = { { position : 'absolute' , inset : 0 , '& .tlui-layout' : { display : 'none' } } } >
< Tldraw
< Tldraw
shapeUtils = { examCanvasShapeUtils as any }
shapeUtils = { examCanvasShapeUtils as any }
tools = { examCanvasTools as any }
tools = { examCanvasTools as any }
@@ -260,28 +303,24 @@ const ExamTemplateSetupInner: React.FC = () => {
editorRef . current = editor
editorRef . current = editor
editor . user . updateUserPreferences ( { colorScheme : theme.palette.mode === 'dark' ? 'dark' : 'light' } )
editor . user . updateUserPreferences ( { colorScheme : theme.palette.mode === 'dark' ? 'dark' : 'light' } )
editor . store . listen ( ( ) = > setDirty ( true ) , { scope : 'document' } )
editor . store . listen ( ( ) = > setDirty ( true ) , { scope : 'document' } )
applyDocViewConstraints ( editor , [ ] )
editor . resetZoom ( )
if ( template ) loadShapes ( editor , shapesFromTemplate ( template , pageGeometriesRef . current ) ) ; else seedGuide ( editor )
if ( template ) loadShapes ( editor , shapesFromTemplate ( template , pageGeometriesRef . current ) ) ; else seedGuide ( editor )
bringDomainShapesToFront ( editor )
bringDomainShapesToFront ( editor )
} }
} }
/ >
/ >
< / Box >
< / 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' } } >
{ /* Guide toggle */ }
< Button startIcon = { < ArrowBackIcon / > } onClick = { ( ) = > navigate ( '/exam-marker' ) } size = "small" > Back < / Button >
< Tooltip title = { guideOpen ? 'Hide guide' : 'Show setup guide' } placement = "left" >
< Divider orientation = "vertical" flexItem / >
< IconButton onClick = { ( ) = > setGuideOpen ( ( v ) = > ! v ) } size = "small" sx = { { position : 'absolute' , right : 16 , bottom : 16 , zIndex : 1001 , bgcolor : 'background.paper' , boxShadow : 2 , '&:hover' : { bgcolor : 'background.paper' } } } >
< Box sx = { { minWidth : 0 , flex : 1 } } >
< HelpOutlineIcon fontSize = "small" color = { guideOpen ? 'primary' : 'action' } / >
< Typography variant = "subtitle1" noWrap > { template ? . title ? ? 'Template setup' } < / Typography >
< / IconButton >
< Typography variant = "caption" color = "text.secondary" > Exam Marker › Setup · coloured tools map to persisted regions ; boundary start / end pairs can span pages . < / Typography >
< / Tooltip >
< / Box >
< 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' } } >
{ /* Guide panel — collapsible */ }
< Stack spacing = { 1 } > { toolButtons } < / Stack >
< Collapse in = { guideOpen } sx = { { position : 'absolute' , right : 16 , bottom : 48 , zIndex : 1000 , maxWidth : 440 } } >
< / Paper >
< Paper elevation = { 4 } sx = { { 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" sx = { { mb : 1 } } >
< Typography variant = "body2" color = "text.secondary" sx = { { mb : 1 } } >
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 .
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 .
@@ -294,15 +333,19 @@ const ExamTemplateSetupInner: React.FC = () => {
< / Stack >
< / Stack >
< Divider sx = { { my : 1 } } / >
< Divider sx = { { my : 1 } } / >
< Typography variant = "caption" color = "text.secondary" display = "block" > Multi - page boundary pairing < / Typography >
< 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 = "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 } } >
< 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 ' }
PDF : { pdfStatus === 'ready' ? 'loaded' : pdfStatus === 'loading' ? 'loading…' : pdfStatus === 'missing' ? 'no source PDF' : pdfError ? ? 'failed' }
< / Typography >
< / Typography >
< / Paper >
< / Paper >
< / Collapse >
{ loading && < Box sx = { { position : 'absolute' , inset : 0 , display : 'grid' , placeItems : 'center' , bgcolor : 'rgba(15,23,42,.18)' } } > < CircularProgress / > < / Box > }
{ /* Conflict alert */ }
{ conflict && < Alert severity = "warning" sx = { { position : 'absolute' , top : 86 , right : 16 , maxWidth : 560 } } onClose = { ( ) = > setConflict ( null ) } > { conflict } < / Alert > }
{ conflict && < Alert severity = "warning" sx = { { position : 'absolute' , top : 16 , right : 16 , maxWidth : 560 , zIndex : 1001 } } onClose = { ( ) = > setConflict ( null ) } > { conflict } < / Alert > }
< / Box >
< / Box >
{ loading && < Box sx = { { position : 'absolute' , inset : 0 , display : 'grid' , placeItems : 'center' , bgcolor : 'rgba(15,23,42,.18)' , zIndex : 10 } } > < CircularProgress / > < / Box > }
< Snackbar open = { ! ! error } autoHideDuration = { 8000 } onClose = { ( ) = > setError ( null ) } > < Alert severity = "error" onClose = { ( ) = > setError ( null ) } > { error } < / Alert > < / Snackbar >
< Snackbar open = { ! ! error } autoHideDuration = { 8000 } onClose = { ( ) = > setError ( null ) } > < Alert severity = "error" onClose = { ( ) = > setError ( null ) } > { error } < / Alert > < / Snackbar >
< / Box >
< / Box >
)
)