diff --git a/src/AppRoutes.admin.test.tsx b/src/AppRoutes.admin.test.tsx index a4346e5..d7ea7f3 100644 --- a/src/AppRoutes.admin.test.tsx +++ b/src/AppRoutes.admin.test.tsx @@ -35,6 +35,7 @@ vi.mock('./pages/exam', () => ({ ExamDashboardPage: () =>
Exam Marker
vi.mock('./pages/user/calendarPage', () => ({ default: () =>
Calendar
})); vi.mock('./pages/user/settingsPage', () => ({ default: () =>
Settings
})); vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () =>
TLDraw Dev
})); +vi.mock('./pages/tldraw/ExamMarkerSpikePage', () => ({ default: () =>
Exam Marker Spike
})); vi.mock('./pages/tldraw/devPage', () => ({ default: () =>
Dev
})); vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () =>
Teacher Planner
})); vi.mock('./pages/morphicPage', () => ({ default: () =>
Morphic
})); diff --git a/src/AppRoutes.tsx b/src/AppRoutes.tsx index f091bfb..93da209 100644 --- a/src/AppRoutes.tsx +++ b/src/AppRoutes.tsx @@ -16,6 +16,7 @@ import AdminDashboard from './pages/auth/adminPage'; import PlatformAdminPage from './pages/auth/PlatformAdminPage'; import TLDrawDevPage from './pages/tldraw/devPlayerPage'; import DevPage from './pages/tldraw/devPage'; +import ExamMarkerSpikePage from './pages/tldraw/ExamMarkerSpikePage'; import TeacherPlanner from './pages/react-flow/teacherPlanner'; import MorphicPage from './pages/morphicPage'; import NotFound from './pages/user/NotFound'; @@ -186,6 +187,7 @@ const AppRoutes: React.FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/pages/exam/ExamDashboardPage.tsx b/src/pages/exam/ExamDashboardPage.tsx index 500c45f..c3b339a 100644 --- a/src/pages/exam/ExamDashboardPage.tsx +++ b/src/pages/exam/ExamDashboardPage.tsx @@ -46,6 +46,7 @@ const ExamDashboardPage: React.FC = () => { const [createOpen, setCreateOpen] = useState(false); const [title, setTitle] = useState(''); const [subject, setSubject] = useState(''); + const [sourcePdf, setSourcePdf] = useState(null); const [saving, setSaving] = useState(false); const load = useCallback(async () => { @@ -66,6 +67,18 @@ const ExamDashboardPage: React.FC = () => { void load(); }, [load, instituteId]); + const handleOpenCreate = () => { + setCreateOpen(true); + }; + + const handleCloseCreate = () => { + if (saving) return; + setCreateOpen(false); + setTitle(''); + setSubject(''); + setSourcePdf(null); + }; + const handleCreate = async () => { if (!title.trim()) return; setSaving(true); @@ -74,10 +87,12 @@ const ExamDashboardPage: React.FC = () => { title: title.trim(), subject: subject.trim() || undefined, institute_id: instituteId, + source_pdf: sourcePdf, }); setCreateOpen(false); setTitle(''); setSubject(''); + setSourcePdf(null); navigate(`/exam-marker/${created.id}/setup`); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -112,7 +127,7 @@ const ExamDashboardPage: React.FC = () => { Build a template for an exam paper, then run marking batches against your classes. - @@ -134,7 +149,7 @@ const ExamDashboardPage: React.FC = () => { Create your first template to start mapping an exam paper. - @@ -144,8 +159,16 @@ const ExamDashboardPage: React.FC = () => { navigate(`/exam-marker/${t.id}/setup`)} > @@ -175,7 +198,7 @@ const ExamDashboardPage: React.FC = () => { )} - (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm"> + New exam template @@ -193,10 +216,24 @@ const ExamDashboardPage: React.FC = () => { onChange={(e) => setSubject(e.target.value)} fullWidth /> + + + + {sourcePdf ? sourcePdf.name : 'Optional: upload a PDF source for this template.'} + + - + diff --git a/src/pages/tldraw/ExamMarkerSpikePage.tsx b/src/pages/tldraw/ExamMarkerSpikePage.tsx new file mode 100644 index 0000000..7b013d5 --- /dev/null +++ b/src/pages/tldraw/ExamMarkerSpikePage.tsx @@ -0,0 +1,170 @@ +import React, { useMemo, useRef, useState } from 'react' +import { + Tldraw, + createTLStore, + createTLSchemaFromUtils, + defaultBindingUtils, + defaultShapeUtils, + Editor, + TLAnyBindingUtilConstructor, + TLAnyShapeUtilConstructor, + DEFAULT_SUPPORT_VIDEO_TYPES, + DEFAULT_SUPPORTED_IMAGE_TYPES, +} from '@tldraw/tldraw' +import { allBindingUtils } from '../../utils/tldraw/bindings' +import { allShapeUtils } from '../../utils/tldraw/shapes' +import { customAssets } from '../../utils/tldraw/assets' +import { getUiComponents, getUiOverrides } from '../../utils/tldraw/ui-overrides' +import { HEADER_HEIGHT } from '../Layout' +import { devTools } from '../../utils/tldraw/tools' +import { + activateExamMarkerTool, + EXAM_MARKER_BOX_TYPE, + ExamMarkerBoxShapeUtil, + ExamMarkerBoxTool, + placeExamMarkerSpike, +} from '../../utils/tldraw/exam-marker-spike' + +const spikeShapeUtils = [...allShapeUtils, ExamMarkerBoxShapeUtil] as TLAnyShapeUtilConstructor[] +const spikeBindingUtils = allBindingUtils as TLAnyBindingUtilConstructor[] +const spikeTools = [...devTools, ExamMarkerBoxTool] + +const spikeSchema = createTLSchemaFromUtils({ + shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils], + bindingUtils: [...defaultBindingUtils, ...spikeBindingUtils], +}) + +export default function ExamMarkerSpikePage() { + const editorRef = useRef(null) + const [status, setStatus] = useState('Ready to insert an image and a custom exam-marker box.') + + const store = useMemo(() => { + const nextStore = createTLStore({ + schema: spikeSchema, + shapeUtils: spikeShapeUtils, + bindingUtils: spikeBindingUtils, + }) + ;(nextStore as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable() + return nextStore + }, []) + + const onMount = (editor: Editor) => { + editorRef.current = editor + } + + const insertSpike = () => { + if (!editorRef.current) return + const { imageId, boxId } = placeExamMarkerSpike(editorRef.current) + const toolId = activateExamMarkerTool(editorRef.current) + setStatus(`Inserted image ${imageId} and custom box ${boxId}; active tool is ${toolId}.`) + } + + const focusBox = () => { + if (!editorRef.current) return + const current = editorRef.current.getCurrentPageShapes().find((shape) => shape.type === EXAM_MARKER_BOX_TYPE) + if (!current) { + setStatus('No exam-marker box found yet.') + return + } + const bounds = editorRef.current.getShapePageBounds(current) + if (bounds) { + editorRef.current.zoomToBounds(bounds) + setStatus('Focused the camera on the exam-marker box using getShapePageBounds + zoomToBounds.') + } + } + + const uiOverrides = getUiOverrides(false) + const uiComponents = getUiComponents(false) + + return ( +
+
+
+ Exam-marker spike + {status} +
+ + + +
+ + +
+ ) +} diff --git a/src/services/exam/examRepository.ts b/src/services/exam/examRepository.ts index 1b5160d..8c7055d 100644 --- a/src/services/exam/examRepository.ts +++ b/src/services/exam/examRepository.ts @@ -43,6 +43,24 @@ export const examRepository = { async createTemplate(payload: CreateTemplatePayload): Promise { const headers = await authHeaders(); + + if (payload.source_pdf) { + const form = new FormData(); + form.append('title', payload.title); + if (payload.subject) form.append('subject', payload.subject); + if (payload.exam_id) form.append('exam_id', payload.exam_id); + if (payload.exam_code) form.append('exam_code', payload.exam_code); + if (payload.source_file_id) form.append('source_file_id', payload.source_file_id); + if (payload.page_count !== undefined) form.append('page_count', String(payload.page_count)); + if (payload.institute_id) form.append('institute_id', payload.institute_id); + form.append('source_pdf', payload.source_pdf); + + const res = await axios.post(`${EXAM_BASE}/templates`, form, { + headers, + }); + return res.data; + } + const res = await axios.post(`${EXAM_BASE}/templates`, payload, { headers }); return res.data; }, diff --git a/src/types/exam.types.ts b/src/types/exam.types.ts index 017e0d1..374a232 100644 --- a/src/types/exam.types.ts +++ b/src/types/exam.types.ts @@ -27,6 +27,7 @@ export interface CreateTemplatePayload { exam_id?: string; exam_code?: string; source_file_id?: string; + source_pdf?: File | null; page_count?: number; institute_id?: string; } diff --git a/src/utils/tldraw/exam-marker-spike.test.ts b/src/utils/tldraw/exam-marker-spike.test.ts new file mode 100644 index 0000000..9a87856 --- /dev/null +++ b/src/utils/tldraw/exam-marker-spike.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + Editor, + createTLStore, + createTLSchemaFromUtils, + defaultBindingUtils, + defaultShapeUtils, +} from '@tldraw/tldraw' +import { + EXAM_MARKER_BOX_TYPE, + ExamMarkerBoxShapeUtil, + ExamMarkerBoxTool, + activateExamMarkerTool, + buildExamMarkerSvgDataUrl, + getExamMarkerHitTestPoints, + insertExamMarkerBox, +} from './exam-marker-spike' + +const spikeShapeUtils = [ExamMarkerBoxShapeUtil] +const spikeSchema = createTLSchemaFromUtils({ + shapeUtils: [...defaultShapeUtils, ...spikeShapeUtils], + bindingUtils: defaultBindingUtils, +}) + +if (typeof globalThis.matchMedia !== 'function') { + vi.stubGlobal('matchMedia', () => ({ + matches: false, + media: '', + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + } as unknown as MediaQueryList)) +} + +function makeEditor() { + const store = createTLStore({ + schema: spikeSchema, + shapeUtils: spikeShapeUtils, + bindingUtils: defaultBindingUtils, + }) + ;(store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable() + + return new Editor({ + shapeUtils: spikeShapeUtils, + bindingUtils: defaultBindingUtils, + tools: [ExamMarkerBoxTool], + store, + getContainer: () => document.body, + }) +} + +describe('exam-marker spike tldraw helpers', () => { + let editor: Editor + + beforeEach(() => { + editor = makeEditor() + }) + + afterEach(() => { + editor.dispose() + }) + + it('creates a custom box shape and supports point hit-testing + page bounds', () => { + const boxId = insertExamMarkerBox(editor) + const box = editor.getShape(boxId) + + expect(box?.type).toBe(EXAM_MARKER_BOX_TYPE) + expect(editor.getShapePageBounds(boxId)).toBeTruthy() + + const boxPoint = getExamMarkerHitTestPoints(editor, boxId) + expect(boxPoint?.bounds).toBeTruthy() + expect(editor.getShapesAtPoint(boxPoint!.center).some((shape) => shape.id === boxId)).toBe(true) + + const viewport = editor.getViewportPageBounds() + expect(viewport.w).toBeGreaterThan(0) + expect(viewport.h).toBeGreaterThan(0) + expect(() => editor.zoomToBounds(boxPoint!.bounds)).not.toThrow() + }) + + it('registers the custom BaseBoxShapeTool and emits a locked svg asset URL', () => { + expect(activateExamMarkerTool(editor)).toBe(EXAM_MARKER_BOX_TYPE) + expect(editor.getCurrentToolId()).toBe(EXAM_MARKER_BOX_TYPE) + + const svgUrl = buildExamMarkerSvgDataUrl() + expect(svgUrl.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true) + expect(decodeURIComponent(svgUrl.split(',')[1])).toContain('Exam scan') + }) +}) diff --git a/src/utils/tldraw/exam-marker-spike.tsx b/src/utils/tldraw/exam-marker-spike.tsx new file mode 100644 index 0000000..b7e6c09 --- /dev/null +++ b/src/utils/tldraw/exam-marker-spike.tsx @@ -0,0 +1,197 @@ +import React from 'react' +import { + BaseBoxShapeTool, + BaseBoxShapeUtil, + Editor, + HTMLContainer, + T, + TLBaseBoxShape, + TLShapeId, + createShapeId, + toDomPrecision, +} from '@tldraw/tldraw' + +export const EXAM_MARKER_BOX_TYPE = 'exam-marker-box' as const + +export type ExamMarkerBoxShape = TLBaseBoxShape & { + type: typeof EXAM_MARKER_BOX_TYPE +} + +export class ExamMarkerBoxShapeUtil extends BaseBoxShapeUtil { + static override type = EXAM_MARKER_BOX_TYPE + static override props = { + w: T.number, + h: T.number, + } + + override getDefaultProps(): ExamMarkerBoxShape['props'] { + return { + w: 320, + h: 180, + } + } + + override component(shape: ExamMarkerBoxShape) { + return ( + +
+
+ Exam marker spike +
+
+ This custom box shape uses the app's tldraw schema + BaseBoxShapeTool path. +
+
+ Drag to resize · use the spike tool to create more +
+
+
+ ) + } +} + +export class ExamMarkerBoxTool extends BaseBoxShapeTool { + static override id = EXAM_MARKER_BOX_TYPE + static override initial = 'pointing' + shapeType = EXAM_MARKER_BOX_TYPE +} + +export function buildExamMarkerSvgDataUrl(label = 'Exam scan') { + const svg = ` + + + + + + + + + + ${label} + + Question 1 + Show your working and annotate the image. + + Answer space + + + + Inserted as a built-in tldraw image shape. + + `.trim() + + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` +} + +function getViewportAnchor(editor: Editor) { + const bounds = editor.getViewportPageBounds() + return { + x: bounds.x + bounds.w / 2, + y: bounds.y + bounds.h / 2, + } +} + +export function insertExamMarkerImage(editor: Editor) { + const anchor = getViewportAnchor(editor) + const imageId = createShapeId() + + editor.createShape({ + id: imageId, + type: 'image', + x: anchor.x - 320, + y: anchor.y - 240, + props: { + url: buildExamMarkerSvgDataUrl(), + w: 640, + h: 480, + name: 'exam-marker-sample.svg', + }, + }) + + return imageId +} + +export function insertExamMarkerBox(editor: Editor) { + const anchor = getViewportAnchor(editor) + const boxId = createShapeId() + + editor.createShape({ + id: boxId, + type: EXAM_MARKER_BOX_TYPE, + x: anchor.x + 180, + y: anchor.y - 120, + props: { + w: 320, + h: 180, + }, + }) + + return boxId +} + +export function placeExamMarkerSpike(editor: Editor) { + const imageId = insertExamMarkerImage(editor) + const boxId = insertExamMarkerBox(editor) + + const box = editor.getShape(boxId) + if (box) { + const bounds = editor.getShapePageBounds(box) + if (bounds) editor.zoomToBounds(bounds) + } + + return { imageId, boxId } +} + +export function activateExamMarkerTool(editor: Editor) { + editor.setCurrentTool(EXAM_MARKER_BOX_TYPE) + return editor.getCurrentToolId() +} + +export function getExamMarkerHitTestPoints(editor: Editor, shapeId: TLShapeId) { + const shape = editor.getShape(shapeId) + if (!shape) return null + + const bounds = editor.getShapePageBounds(shape) + if (!bounds) return null + + return { + center: { + x: bounds.x + bounds.w / 2, + y: bounds.y + bounds.h / 2, + }, + bounds, + } +}