Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4a1d29298 |
@@ -0,0 +1,60 @@
|
|||||||
|
const { chromium } = require('playwright');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.0.251:13000';
|
||||||
|
const EMAIL = process.env.VITE_TEST_TEACHER_EMAIL || '[email protected]';
|
||||||
|
const PASSWORD = process.env.VITE_TEST_TEACHER_PASSWORD || process.env.SEED_TEACHER_PASSWORD;
|
||||||
|
const TEMPLATE_ID = process.env.TEMPLATE_ID || '31d92cf3-9bbd-4a7e-b2dc-b37f8b69bc34';
|
||||||
|
const OUT = process.env.OUT || '/out';
|
||||||
|
|
||||||
|
async function run(mode) {
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({ colorScheme: mode, viewport: { width: 1440, height: 980 } });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const messages = [];
|
||||||
|
page.on('console', msg => messages.push(`${msg.type()}: ${msg.text()}`));
|
||||||
|
page.on('pageerror', err => messages.push(`pageerror: ${err.message}`));
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' });
|
||||||
|
if (await page.getByLabel('Email').count()) {
|
||||||
|
if (!PASSWORD) throw new Error('missing password env');
|
||||||
|
await page.getByLabel('Email').fill(EMAIL);
|
||||||
|
await page.getByLabel('Password').fill(PASSWORD);
|
||||||
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
await page.waitForURL(url => /\/dashboard|\/exam-marker|\/node\//.test(url.pathname), { timeout: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${BASE}/exam-marker/${TEMPLATE_ID}/setup`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('[data-testid="exam-template-setup-canvas"]', { timeout: 20000 });
|
||||||
|
await page.waitForTimeout(3500);
|
||||||
|
|
||||||
|
const text = await page.locator('body').innerText();
|
||||||
|
const checks = {
|
||||||
|
hasBoundaryTool: text.includes('Boundary'),
|
||||||
|
hasPartTool: text.includes('Part'),
|
||||||
|
hasResponseTool: text.includes('Response'),
|
||||||
|
hasContextTool: text.includes('Context'),
|
||||||
|
hasQuestionNumberTool: text.includes('Q Number'),
|
||||||
|
hasMarkAreaTool: text.includes('Mark Area'),
|
||||||
|
hasReferenceTool: text.includes('Reference'),
|
||||||
|
hasFurnitureTool: text.includes('Furniture'),
|
||||||
|
hasHintPanel: text.includes('Setup guide') && text.includes('Boundary pairing preview'),
|
||||||
|
hasMultiPageCopy: text.includes('later page') || text.includes('multi-page'),
|
||||||
|
hasPdfStatus: text.includes('PDF backdrop:'),
|
||||||
|
crashOverlay: text.includes('Template setup canvas crashed'),
|
||||||
|
};
|
||||||
|
await page.screenshot({ path: `${OUT}/s4-9c-${mode}.png`, fullPage: true });
|
||||||
|
await browser.close();
|
||||||
|
return { mode, url: `${BASE}/exam-marker/${TEMPLATE_ID}/setup`, checks, consoleMessages: messages };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
const results = [];
|
||||||
|
for (const mode of ['light', 'dark']) results.push(await run(mode));
|
||||||
|
fs.writeFileSync(`${OUT}/s4-9c-smoke.json`, JSON.stringify(results, null, 2));
|
||||||
|
const failures = results.flatMap(r => Object.entries(r.checks).filter(([k,v]) => k === 'crashOverlay' ? v : !v).map(([k]) => `${r.mode}:${k}`));
|
||||||
|
const consoleErrors = results.flatMap(r => r.consoleMessages.filter(m => /^(error|pageerror):/i.test(m) && !/Failed to load resource/i.test(m)));
|
||||||
|
console.log(JSON.stringify({ results, failures, consoleErrors }, null, 2));
|
||||||
|
if (failures.length || consoleErrors.length) process.exit(1);
|
||||||
|
})().catch(err => { console.error(err); process.exit(1); });
|
||||||
@@ -5,6 +5,14 @@ import { Alert, Box, Button, Chip, CircularProgress, Divider, Paper, Snackbar, S
|
|||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
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 HorizontalRuleIcon from '@mui/icons-material/HorizontalRule'
|
||||||
|
import CropSquareIcon from '@mui/icons-material/CropSquare'
|
||||||
|
import EditNoteIcon from '@mui/icons-material/EditNote'
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility'
|
||||||
|
import TagIcon from '@mui/icons-material/Tag'
|
||||||
|
import GradingIcon from '@mui/icons-material/Grading'
|
||||||
|
import AttachFileIcon from '@mui/icons-material/AttachFile'
|
||||||
|
import HideSourceIcon from '@mui/icons-material/HideSource'
|
||||||
import '@tldraw/tldraw/tldraw.css'
|
import '@tldraw/tldraw/tldraw.css'
|
||||||
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
import { Editor, Tldraw, createShapeId, TLShape } from '@tldraw/tldraw'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
@@ -13,20 +21,20 @@ 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 { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
import { CanvasPageGeometry, ExamCanvasShapeModel, PAGE_HEIGHT, PAGE_WIDTH, isUuid, newDomainId, pageForY, serializeCanvasShapes, shapesFromTemplate } from '../../../utils/exam-canvas/model'
|
||||||
import { PDF_PAGE_SHAPE_TYPE, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
import { PDF_PAGE_SHAPE_TYPE, examCanvasShapeUtils, examCanvasTools, ExamCanvasTLShape, SHAPE_TYPES, isPdfPageShape, shapeTypeToKind } from './examCanvasShapes'
|
||||||
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
import { loadPdfPageImages, PdfPageImage } from './pdfLoader'
|
||||||
|
|
||||||
const TOOLS = [
|
const TOOLS = [
|
||||||
{ id: 'select', label: 'Select', tip: 'Move, resize, or delete shapes.', color: 'inherit' as const },
|
{ id: 'select', label: 'Select', tip: 'Move, resize, delete, or inspect attachment pills.', color: 'inherit' as const, icon: <MouseIcon fontSize="small" /> },
|
||||||
{ 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', tip: 'Place a top line, then a bottom line; the bottom can be on a later PDF page for multi-page questions.', color: 'error' as const, icon: <HorizontalRuleIcon fontSize="small" /> },
|
||||||
{ 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', tip: 'Draw the markable sub-question box inside a boundary pair.', color: 'warning' as const, icon: <CropSquareIcon fontSize="small" /> },
|
||||||
{ 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', tip: 'Draw around where the student writes; saved with response_form=lines.', color: 'primary' as const, icon: <EditNoteIcon fontSize="small" /> },
|
||||||
{ 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', tip: 'Draw around stimulus/context material; saved with context_type=generic.', color: 'secondary' as const, icon: <VisibilityIcon fontSize="small" /> },
|
||||||
{ 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', tip: 'Box the printed question number for OCR and template checking.', color: 'success' as const, icon: <TagIcon fontSize="small" /> },
|
||||||
{ 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', tip: 'Box printed marks such as [2] or Total for Question X.', color: 'success' as const, icon: <GradingIcon fontSize="small" /> },
|
||||||
{ id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box student resources/reference material.', color: 'info' as const },
|
{ id: SHAPE_TYPES.reference, label: 'Reference', tip: 'Box formula sheets, data sheets, appendices, or other student resources.', color: 'info' as const, icon: <AttachFileIcon fontSize="small" /> },
|
||||||
{ id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins/page numbers/ignored decoration.', color: 'inherit' as const },
|
{ id: SHAPE_TYPES.furniture, label: 'Furniture', tip: 'Mark margins, page numbers, blank extra space, or decoration to ignore.', color: 'inherit' as const, icon: <HideSourceIcon fontSize="small" /> },
|
||||||
]
|
]
|
||||||
|
|
||||||
const PAGE_START_X = 260
|
const PAGE_START_X = 260
|
||||||
@@ -121,6 +129,27 @@ function syncPdfPages(editor: Editor, pages: PdfPageImage[]) {
|
|||||||
try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ }
|
try { editor.sendToBack(ids as any) } catch { /* tldraw 3 keeps creation order behind later region shapes */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type BoundaryPairSummary = { index: number; label: string; startPage: number; endPage: number; multiPage: boolean }
|
||||||
|
|
||||||
|
function boundaryPairSummaries(editor: Editor | null, pages: CanvasPageGeometry[]): BoundaryPairSummary[] {
|
||||||
|
if (!editor) return []
|
||||||
|
const boundaries = editor.getCurrentPageShapes()
|
||||||
|
.map(modelFromTLShape)
|
||||||
|
.filter((shape): shape is ExamCanvasShapeModel => shape?.kind === 'boundary')
|
||||||
|
.sort((a, b) => (pageForY(a.y + a.h / 2, pages) - pageForY(b.y + b.h / 2, pages)) || (a.y - b.y))
|
||||||
|
const summaries: BoundaryPairSummary[] = []
|
||||||
|
for (let i = 0; i < boundaries.length; i += 2) {
|
||||||
|
const top = boundaries[i]
|
||||||
|
const bottom = boundaries[i + 1]
|
||||||
|
if (!top || !bottom) break
|
||||||
|
const startPage = pageForY(top.y + top.h / 2, pages)
|
||||||
|
const endPage = pageForY(bottom.y + bottom.h / 2, pages)
|
||||||
|
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${summaries.length + 1}`
|
||||||
|
summaries.push({ index: summaries.length + 1, label, startPage, endPage, multiPage: startPage !== endPage })
|
||||||
|
}
|
||||||
|
return summaries
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -147,6 +176,11 @@ 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 [boundaryPairs, setBoundaryPairs] = useState<BoundaryPairSummary[]>([])
|
||||||
|
|
||||||
|
const refreshBoundaryPairs = useCallback(() => {
|
||||||
|
setBoundaryPairs(boundaryPairSummaries(editorRef.current, pageGeometriesRef.current))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
@@ -173,6 +207,9 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
if (editor) {
|
if (editor) {
|
||||||
syncPdfPages(editor, pages)
|
syncPdfPages(editor, pages)
|
||||||
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
loadShapes(editor, shapesFromTemplate(detail, geometries))
|
||||||
|
setBoundaryPairs(boundaryPairSummaries(editor, geometries))
|
||||||
|
} else {
|
||||||
|
setBoundaryPairs([])
|
||||||
}
|
}
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -182,7 +219,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [templateId])
|
}, [templateId, refreshBoundaryPairs])
|
||||||
|
|
||||||
useEffect(() => { void load() }, [load])
|
useEffect(() => { void load() }, [load])
|
||||||
|
|
||||||
@@ -197,6 +234,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
const saved = await examRepository.replaceTemplate(templateId, payload)
|
const saved = await examRepository.replaceTemplate(templateId, payload)
|
||||||
setTemplate(saved)
|
setTemplate(saved)
|
||||||
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
|
loadShapes(editor, shapesFromTemplate(saved, pageGeometriesRef.current))
|
||||||
|
refreshBoundaryPairs()
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = apiMessage(e)
|
const msg = apiMessage(e)
|
||||||
@@ -205,7 +243,7 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}, [template, templateId])
|
}, [template, templateId, refreshBoundaryPairs])
|
||||||
|
|
||||||
const toolButtons = useMemo(() => TOOLS.map((tool) => (
|
const toolButtons = useMemo(() => TOOLS.map((tool) => (
|
||||||
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
<Tooltip title={tool.tip} key={tool.id} placement="right">
|
||||||
@@ -213,7 +251,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.icon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
@@ -227,9 +265,93 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)), [activeTool])
|
)), [activeTool])
|
||||||
|
|
||||||
|
const canvasCss = {
|
||||||
|
'& .tlui-layout': { display: 'none' },
|
||||||
|
'& .exam-canvas-shape': {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
position: 'relative',
|
||||||
|
border: '2px var(--exam-shape-border-style) var(--exam-shape-stroke)',
|
||||||
|
borderRadius: 'var(--exam-shape-radius)',
|
||||||
|
background: 'var(--exam-shape-fill)',
|
||||||
|
color: 'var(--exam-shape-stroke)',
|
||||||
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 0.75,
|
||||||
|
padding: 1,
|
||||||
|
boxShadow: '0 10px 22px rgba(15,23,42,0.10)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
'& .exam-canvas-shape--boundary': {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: 8,
|
||||||
|
padding: '0 64px',
|
||||||
|
background: 'transparent',
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
'& .exam-canvas-shape__main-label': {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 900,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
background: 'rgba(255,255,255,0.90)',
|
||||||
|
border: '1px solid rgba(15,23,42,0.12)',
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '2px 8px',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 0.5,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
},
|
||||||
|
'& .exam-canvas-shape__icon': { minWidth: 14, textAlign: 'center' },
|
||||||
|
'& .exam-canvas-shape__badge': {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 800,
|
||||||
|
letterSpacing: 0.7,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
background: 'rgba(255,255,255,0.68)',
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '2px 7px',
|
||||||
|
color: 'inherit',
|
||||||
|
},
|
||||||
|
'& .exam-canvas-shape__endcap': {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 900,
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: '1px 7px',
|
||||||
|
background: 'var(--exam-shape-stroke)',
|
||||||
|
color: '#fff',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.16)',
|
||||||
|
},
|
||||||
|
'& .exam-canvas-shape__endcap--left': { left: 6 },
|
||||||
|
'& .exam-canvas-shape__endcap--right': { right: 6 },
|
||||||
|
...(theme.palette.mode === 'dark' ? {
|
||||||
|
'& .exam-canvas-shape': {
|
||||||
|
borderColor: 'var(--exam-shape-stroke-dark)',
|
||||||
|
background: 'var(--exam-shape-fill-dark)',
|
||||||
|
color: 'var(--exam-shape-stroke-dark)',
|
||||||
|
boxShadow: '0 10px 22px rgba(0,0,0,0.28)',
|
||||||
|
},
|
||||||
|
'& .exam-canvas-shape--boundary': { background: 'transparent', boxShadow: 'none' },
|
||||||
|
'& .exam-canvas-shape__main-label, & .exam-canvas-shape__badge': {
|
||||||
|
background: 'rgba(15,23,42,0.84)',
|
||||||
|
borderColor: 'rgba(255,255,255,0.16)',
|
||||||
|
},
|
||||||
|
'& .exam-canvas-shape__endcap': { background: 'var(--exam-shape-stroke-dark)', color: '#0f172a' },
|
||||||
|
} : {}),
|
||||||
|
}
|
||||||
|
|
||||||
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' }}>
|
||||||
<Box sx={{ position: 'absolute', inset: 0, '& .tlui-layout': { display: 'none' } }} data-testid="exam-template-setup-canvas">
|
<Box sx={{ position: 'absolute', inset: 0, ...canvasCss }} data-testid="exam-template-setup-canvas">
|
||||||
<Tldraw
|
<Tldraw
|
||||||
shapeUtils={examCanvasShapeUtils as any}
|
shapeUtils={examCanvasShapeUtils as any}
|
||||||
tools={examCanvasTools as any}
|
tools={examCanvasTools as any}
|
||||||
@@ -239,8 +361,9 @@ const ExamTemplateSetupInner: React.FC = () => {
|
|||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
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); refreshBoundaryPairs() }, { scope: 'document' })
|
||||||
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
if (template) loadShapes(editor, shapesFromTemplate(template, pageGeometriesRef.current)); else seedGuide(editor)
|
||||||
|
refreshBoundaryPairs()
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -260,11 +383,27 @@ 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">
|
||||||
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.
|
Start with boundaries: place the top line, then the bottom line. If a question continues, scroll to the later page and place the bottom boundary there. Next draw Part boxes, then Response/Context/metadata regions; Save links regions by spatial containment.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap sx={{ mt: 1 }}>
|
||||||
|
<Chip size="small" color="error" variant="outlined" label="Boundary = paired red lines" />
|
||||||
|
<Chip size="small" color="warning" variant="outlined" label="Part = amber markable box" />
|
||||||
|
<Chip size="small" color="primary" variant="outlined" label="Response = blue writing area" />
|
||||||
|
<Chip size="small" color="secondary" variant="outlined" label="Context = purple stimulus" />
|
||||||
|
</Stack>
|
||||||
|
<Box sx={{ mt: 1.25, p: 1, borderRadius: 2, bgcolor: (t) => t.palette.mode === 'dark' ? 'rgba(248,113,113,0.12)' : 'rgba(239,68,68,0.06)', border: '1px dashed', borderColor: 'error.main' }}>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 800, display: 'block' }}>Boundary pairing preview</Typography>
|
||||||
|
{boundaryPairs.length ? boundaryPairs.slice(0, 4).map((pair) => (
|
||||||
|
<Typography key={`${pair.index}-${pair.label}`} variant="caption" color={pair.multiPage ? 'warning.main' : 'text.secondary'} sx={{ display: 'block' }}>
|
||||||
|
{pair.label}: p{pair.startPage} top → p{pair.endPage} bottom{pair.multiPage ? ' · multi-page span' : ''}
|
||||||
|
</Typography>
|
||||||
|
)) : (
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>Draw two Boundary lines to preview the saved question span.</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
<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 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>
|
||||||
|
|||||||
@@ -36,34 +36,42 @@ export type ExamCanvasTLShape = TLBaseBoxShape & {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const palette: Record<ExamCanvasShapeKind, { stroke: string; fill: string; dash?: string; label: string }> = {
|
const palette: Record<ExamCanvasShapeKind, { stroke: string; strokeDark: string; fill: string; fillDark: string; dash?: string; label: string; icon: string; badge: string }> = {
|
||||||
boundary: { stroke: '#ef4444', fill: 'rgba(239,68,68,0.06)', dash: '8 6', label: 'Boundary' },
|
boundary: { stroke: '#dc2626', strokeDark: '#f87171', fill: 'rgba(239,68,68,0.06)', fillDark: 'rgba(248,113,113,0.12)', dash: '12 7', label: 'Boundary', icon: '↕', badge: 'TOP / BOTTOM' },
|
||||||
part: { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', label: 'Part' },
|
part: { stroke: '#d97706', strokeDark: '#fbbf24', fill: 'rgba(245,158,11,0.18)', fillDark: 'rgba(251,191,36,0.26)', label: 'Part', icon: '□', badge: 'MARKABLE BOX' },
|
||||||
response: { stroke: '#2563eb', fill: 'rgba(37,99,235,0.16)', label: 'Response' },
|
response: { stroke: '#2563eb', strokeDark: '#60a5fa', fill: 'rgba(37,99,235,0.17)', fillDark: 'rgba(96,165,250,0.32)', label: 'Response', icon: '✎', badge: 'STUDENT WRITES' },
|
||||||
context: { stroke: '#7c3aed', fill: 'rgba(124,58,237,0.14)', dash: '6 5', label: 'Context' },
|
context: { stroke: '#7c3aed', strokeDark: '#c4b5fd', fill: 'rgba(124,58,237,0.14)', fillDark: 'rgba(167,139,250,0.28)', dash: '7 5', label: 'Context', icon: '◌', badge: 'STIMULUS' },
|
||||||
question_number: { stroke: '#0f766e', fill: 'rgba(15,118,110,0.14)', label: 'Question #' },
|
question_number: { stroke: '#0f766e', strokeDark: '#5eead4', fill: 'rgba(15,118,110,0.15)', fillDark: 'rgba(45,212,191,0.24)', label: 'Question #', icon: '#', badge: 'OCR LABEL' },
|
||||||
mark_area: { stroke: '#16a34a', fill: 'rgba(22,163,74,0.14)', label: 'Marks' },
|
mark_area: { stroke: '#16a34a', strokeDark: '#86efac', fill: 'rgba(22,163,74,0.15)', fillDark: 'rgba(74,222,128,0.24)', label: 'Mark Area', icon: '✓', badge: 'PRINTED MARKS' },
|
||||||
reference: { stroke: '#0891b2', fill: 'rgba(8,145,178,0.14)', label: 'Reference' },
|
reference: { stroke: '#0891b2', strokeDark: '#67e8f9', fill: 'rgba(8,145,178,0.14)', fillDark: 'rgba(34,211,238,0.24)', label: 'Reference', icon: '📎', badge: 'RESOURCE' },
|
||||||
furniture: { stroke: '#64748b', fill: 'rgba(100,116,139,0.12)', dash: '3 5', label: 'Furniture' },
|
furniture: { stroke: '#64748b', strokeDark: '#cbd5e1', fill: 'rgba(100,116,139,0.12)', fillDark: 'rgba(148,163,184,0.22)', dash: '3 5', label: 'Furniture', icon: '×', badge: 'IGNORE' },
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = palette[kind] ?? palette.response
|
||||||
const isBoundary = kind === 'boundary'
|
const isBoundary = kind === 'boundary'
|
||||||
|
const label = shape.props.label || p.label
|
||||||
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={{
|
<div
|
||||||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isBoundary ? 2 : 1.5}px solid ${p.stroke}`,
|
className={`exam-canvas-shape exam-canvas-shape--${kind.replace('_', '-')}`}
|
||||||
borderStyle: p.dash ? 'dashed' : 'solid', borderRadius: isBoundary ? 999 : 10,
|
style={{
|
||||||
background: isBoundary ? 'transparent' : p.fill, color: p.stroke, fontFamily: 'Inter, system-ui, sans-serif',
|
'--exam-shape-stroke': p.stroke,
|
||||||
display: 'flex', alignItems: isBoundary ? 'center' : 'flex-start', justifyContent: isBoundary ? 'center' : 'space-between',
|
'--exam-shape-stroke-dark': p.strokeDark,
|
||||||
padding: isBoundary ? '0 8px' : 8, boxShadow: isBoundary ? 'none' : '0 10px 22px rgba(15,23,42,0.08)', overflow: 'hidden'
|
'--exam-shape-fill': p.fill,
|
||||||
}}>
|
'--exam-shape-fill-dark': p.fillDark,
|
||||||
<span style={{ fontSize: 12, fontWeight: 800, textTransform: 'uppercase', letterSpacing: 0.6, background: 'rgba(255,255,255,0.84)', borderRadius: 999, padding: '2px 7px' }}>
|
'--exam-shape-border-style': p.dash ? 'dashed' : 'solid',
|
||||||
{shape.props.label || p.label}
|
'--exam-shape-radius': isBoundary ? '999px' : kind === 'part' ? '6px' : '10px',
|
||||||
|
} as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<span className="exam-canvas-shape__main-label">
|
||||||
|
<span className="exam-canvas-shape__icon" aria-hidden="true">{p.icon}</span>
|
||||||
|
<span>{label}</span>
|
||||||
</span>
|
</span>
|
||||||
{!isBoundary && shape.props.questionId && <span style={{ fontSize: 11, fontWeight: 700, opacity: .75 }}>Attached</span>}
|
{!isBoundary && <span className="exam-canvas-shape__badge">{shape.props.questionId ? 'Attached' : p.badge}</span>}
|
||||||
|
{isBoundary && <span className="exam-canvas-shape__endcap exam-canvas-shape__endcap--left">{label.toLowerCase().includes('end') ? 'B' : 'T'}</span>}
|
||||||
|
{isBoundary && <span className="exam-canvas-shape__endcap exam-canvas-shape__endcap--right">{label.toLowerCase().includes('end') ? 'Bottom' : 'Top'}</span>}
|
||||||
</div>
|
</div>
|
||||||
</HTMLContainer>
|
</HTMLContainer>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user