import React from 'react' import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T, TLBaseBoxShape, Vec, toDomPrecision } from '@tldraw/tldraw' import type { TLHandle } from '@tldraw/tldraw' import { PAGE_WIDTH } from '../../../utils/exam-canvas/model' import type { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model' import type { ExamTemplateSource } from '../../../types/exam.types' export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page' export const SHAPE_TYPES = { boundary: 'exam-boundary', part: 'exam-part', response: 'exam-region-response', context: 'exam-region-context', question_number: 'exam-region-question-number', mark_area: 'exam-region-mark-area', reference: 'exam-region-reference', furniture: 'exam-region-furniture', } as const export type ExamPdfPageTLShape = TLBaseBoxShape & { type: typeof PDF_PAGE_SHAPE_TYPE props: { w: number; h: number; src: string; pageNumber: number } } export type ExamCanvasTLShape = TLBaseBoxShape & { type: typeof SHAPE_TYPES[keyof typeof SHAPE_TYPES] props: { w: number h: number label: string kind: ExamCanvasShapeKind maxMarks?: number responseForm?: string contextType?: string questionId?: string | null parentId?: string | null isContainer?: boolean depth?: number description?: string linkLabel?: string domainId?: string source?: 'manual' | 'ai' confirmed?: boolean confidence?: number | null derivation?: string | null reviewFlags?: string } } type CanvasPaletteEntry = { stroke: string fill: string darkStroke: string darkFill: string dash?: string label: string icon: string role: string } export const canvasShapePalette: Record = { 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' }, } // Human-legible names for the extraction service's response forms — so the canvas SHOWS what was // recognised (multiple-choice, draw-on, table…), not just a generic "Response" box. const RESPONSE_FORM_LABEL: Record = { lines: 'ruled lines', 'answer-box': 'answer box', working: 'working space', diagram: 'draw-on', 'tick-boxes': 'multiple choice', table: 'table', blanks: 'fill-in', } 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); } .exam-canvas-shape__flag { background: rgba(251,191,36,.94); color: #78350f; box-shadow: 0 1px 4px rgba(120,53,15,.18); } .exam-canvas-shape__confidence { background: rgba(15,23,42,.82); color: #fff; } [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); } [data-color-mode="dark"] .exam-canvas-shape__flag, .tl-theme__dark .exam-canvas-shape__flag { background: rgba(251,191,36,.88); color: #422006; } ` function confidenceLabel(confidence: number | null | undefined) { return typeof confidence === 'number' ? `${Math.round(confidence * 100)}%` : null } // Unconfirmed AI ghosts fade by confidence (fainter = less certain); confirmed/manual render solid. function ghostOpacity(confidence: number | null | undefined) { if (typeof confidence !== 'number') return 0.72 return 0.5 + 0.4 * Math.max(0, Math.min(1, confidence)) } function reviewFlags(shape: ExamCanvasTLShape): string[] { return (shape.props.reviewFlags ?? '').split('|').map((flag) => flag.trim()).filter(Boolean) } function provenanceTitle(shape: ExamCanvasTLShape, base: string) { const bits = [base] if (shape.props.source === 'ai') bits.push(shape.props.confirmed === false ? 'AI suggestion, unconfirmed' : 'AI, confirmed') const confidence = confidenceLabel(shape.props.confidence) if (confidence) bits.push(`confidence ${confidence}`) if (shape.props.derivation) bits.push(`derivation: ${shape.props.derivation}`) const flags = reviewFlags(shape) if (flags.length) bits.push(`review flags: ${flags.join(', ')}`) return bits.join(' • ') } function renderBoundaryLine(shape: ExamCanvasTLShape) { const p = canvasShapePalette.boundary const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2)) const isAi = shape.props.source === 'ai' const isGhost = isAi && shape.props.confirmed === false const confidence = confidenceLabel(shape.props.confidence) const flags = reviewFlags(shape) return ( {shape.props.label || p.label} {confidence && {confidence}} {flags.slice(0, 2).map((flag, index) => {flag})} ) } // Distinct look for a container box (main question / intermediate part). It frames its children rather than // filling over them: transparent interior + a coloured border whose hue shifts by nesting depth, so // question ⊃ part ⊃ subpart reads as nested frames on the paper. const CONTAINER_DEPTH_STROKE = ['#4f46e5', '#0d9488', '#b45309'] function containerStroke(depth: number) { return CONTAINER_DEPTH_STROKE[Math.min(depth, CONTAINER_DEPTH_STROKE.length - 1)] } function marksPill(maxMarks: number | undefined) { if (typeof maxMarks !== 'number' || maxMarks <= 0) return null return ( {maxMarks} ) } function renderShape(shape: ExamCanvasTLShape) { const kind = shape.props.kind const p = canvasShapePalette[kind] ?? canvasShapePalette.response const isBoundary = kind === 'boundary' const isAiSuggestion = shape.props.source === 'ai' && shape.props.confirmed === false if (isBoundary) return renderBoundaryLine(shape) const isContainer = shape.props.isContainer === true && kind === 'part' const depth = shape.props.depth ?? 0 const stroke = isContainer ? containerStroke(depth) : p.stroke const darkStroke = isContainer ? containerStroke(depth) : p.darkStroke const confidence = confidenceLabel(shape.props.confidence) const flags = reviewFlags(shape) const roleLabel = isContainer ? (depth === 0 ? 'Question: container' : 'Part: container') : `${p.label}: ${p.role}` const title = shape.props.description ? `${provenanceTitle(shape, roleLabel)} • ${shape.props.description}` : provenanceTitle(shape, roleLabel) return (
{shape.props.label || p.label} {marksPill(shape.props.maxMarks)} {confidence && {confidence}} {!confidence && !marksPill(shape.props.maxMarks) && shape.props.questionId && kind !== 'context' && Attached} {flags.length > 0 && {flags.slice(0, 2).join(' · ')}} {kind === 'response' && shape.props.responseForm && {RESPONSE_FORM_LABEL[shape.props.responseForm] ?? shape.props.responseForm}} {/* Context region: a visible tether to its owning question + the figure kind. */} {kind === 'context' && shape.props.linkLabel && → {shape.props.linkLabel}} {kind === 'context' && shape.props.contextType && shape.props.contextType !== 'generic' && {shape.props.contextType}}
) } function defaultProps(kind: ExamCanvasShapeKind, w: number, h: number) { const p = canvasShapePalette[kind] return { w, h, label: p.label, kind, responseForm: kind === 'response' ? 'lines' : undefined, contextType: kind === 'context' ? 'generic' : undefined, source: 'manual' as const, confirmed: true } } 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), parentId: T.optional(T.string), isContainer: T.optional(T.boolean), depth: T.optional(T.number), description: T.optional(T.string), linkLabel: T.optional(T.string), domainId: T.optional(T.string), source: T.optional(T.string), confirmed: T.optional(T.boolean), confidence: T.optional(T.number), derivation: T.optional(T.string), reviewFlags: T.optional(T.string) } const ind = (s: ExamCanvasTLShape | ExamPdfPageTLShape) => class PdfPageUtil extends BaseBoxShapeUtil { 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 ( {'PDF ) } override indicator(shape: ExamPdfPageTLShape) { return ind(shape) } } class BoundaryUtil extends ShapeUtil { static override type = SHAPE_TYPES.boundary static override props = sharedProps override getDefaultProps() { return defaultProps('boundary', PAGE_WIDTH, 8) } override canEdit() { return false } override canResize() { return false } override canBind() { return false } override hideResizeHandles() { return true } override hideRotateHandle() { return true } override hideSelectionBoundsBg() { return true } private pageSpanForY(y: number) { const pages = this.editor.getCurrentPageShapes().filter((shape): shape is ExamPdfPageTLShape => shape.type === PDF_PAGE_SHAPE_TYPE) const hit = pages.find((page) => y >= page.y && y <= page.y + page.props.h) const nearest = hit ?? pages.reduce((best, page) => { if (!best) return page const pageDy = Math.min(Math.abs(y - page.y), Math.abs(y - (page.y + page.props.h))) const bestDy = Math.min(Math.abs(y - best.y), Math.abs(y - (best.y + best.props.h))) return pageDy < bestDy ? page : best }, null) return nearest ? { x: nearest.x, w: nearest.props.w } : { x: 0, w: PAGE_WIDTH } } private normalize(shape: ExamCanvasTLShape): ExamCanvasTLShape { const span = this.pageSpanForY(shape.y + shape.props.h / 2) return { ...shape, x: span.x, rotation: 0, props: { ...shape.props, w: span.w, h: 8, kind: 'boundary' } } } override getGeometry(shape: ExamCanvasTLShape) { const y = shape.props.h / 2 return new Edge2d({ start: new Vec(0, y), end: new Vec(shape.props.w, y) }) } override getHandles(shape: ExamCanvasTLShape): TLHandle[] { return [{ id: 'y', type: 'vertex', index: 'a1' as any, x: shape.props.w / 2, y: shape.props.h / 2, canSnap: false }] } override onBeforeCreate(next: ExamCanvasTLShape) { return this.normalize(next) } override onBeforeUpdate(_prev: ExamCanvasTLShape, next: ExamCanvasTLShape) { return this.normalize(next) } override onTranslate(initial: ExamCanvasTLShape, current: ExamCanvasTLShape): any { return this.normalize({ ...current, x: initial.x }) } override onHandleDrag(shape: ExamCanvasTLShape, { handle }: { handle: TLHandle }): any { return this.normalize({ ...shape, y: shape.y + handle.y - shape.props.h / 2 }) } override component(shape: ExamCanvasTLShape) { return renderShape(shape) } override indicator(shape: ExamCanvasTLShape) { return } } class PartUtil extends BaseBoxShapeUtil { 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 { 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 PartTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.part; static override initial = 'pointing'; shapeType = SHAPE_TYPES.part } class ResponseTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.response; static override initial = 'pointing'; shapeType = SHAPE_TYPES.response } class ContextTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.context; static override initial = 'pointing'; shapeType = SHAPE_TYPES.context } class QuestionNumberTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.question_number; static override initial = 'pointing'; shapeType = SHAPE_TYPES.question_number } class MarkAreaTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.mark_area; static override initial = 'pointing'; shapeType = SHAPE_TYPES.mark_area } class ReferenceTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.reference; static override initial = 'pointing'; shapeType = SHAPE_TYPES.reference } class FurnitureTool extends BaseBoxShapeTool { static override id = SHAPE_TYPES.furniture; static override initial = 'pointing'; shapeType = SHAPE_TYPES.furniture } export const examCanvasShapeUtils = [PdfPageUtil, BoundaryUtil, PartUtil, regionUtil(SHAPE_TYPES.response, 'response'), regionUtil(SHAPE_TYPES.context, 'context'), regionUtil(SHAPE_TYPES.question_number, 'question_number', 170, 80), regionUtil(SHAPE_TYPES.mark_area, 'mark_area', 170, 80), regionUtil(SHAPE_TYPES.reference, 'reference'), regionUtil(SHAPE_TYPES.furniture, 'furniture')] as const export const examCanvasTools = [BoundaryTool, PartTool, ResponseTool, ContextTool, QuestionNumberTool, MarkAreaTool, ReferenceTool, FurnitureTool] as const export function isPdfPageShape(type: string): boolean { return type === PDF_PAGE_SHAPE_TYPE } export function shapeTypeToKind(type: string): ExamCanvasShapeKind | null { const entry = Object.entries(SHAPE_TYPES).find(([, v]) => v === type) return (entry?.[0] as ExamCanvasShapeKind | undefined) ?? null }