Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
Round-trip fix (item 5): serializeCanvasShapes no longer re-derives the question tree from boundary pairs (which flattened 3-deep → 2-tier). The whole tree is now carried as 'part' shapes that hold parentId + isContainer + depth; save preserves the real parent_id chains (persisted parent wins; geometry is only a fallback for manually-drawn shapes). Boundary-pair synthesis is kept but attaches to a covering container box when one exists. New test locks a 3-deep Q→part→subpart round-trip. Draw structure (item 4): shapesFromTemplate now draws containers too (were skipped), each with nesting depth. examCanvasShapes renders container frames (transparent, depth-hued border), PRINTS MARKS on part/container boxes (◆N pill; were never drawn), and NAMES context figures (figure name as label + description tooltip + '→ Q' tether pill; was an anonymous purple box). Context name/description round-trip via exam_response_areas.meta. Threads new shape props through the tldraw util + setup page + types. Adds meta to ExamResponseArea + replace payload. tsc: no new errors; 7/7 model tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GruxHXxfdp4kZCgAMVFgvV
306 lines
20 KiB
TypeScript
306 lines
20 KiB
TypeScript
|
||
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<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' },
|
||
}
|
||
|
||
// 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<string, string> = {
|
||
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 (
|
||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), overflow: 'visible', pointerEvents: 'all' }}>
|
||
<style>{shapeCss}</style>
|
||
<svg width={toDomPrecision(shape.props.w)} height={toDomPrecision(shape.props.h)} aria-label={`${p.label}: ${p.role}`} style={{ display: 'block', overflow: 'visible' }}>
|
||
<line x1={0} x2={toDomPrecision(shape.props.w)} y1={lineY} y2={lineY} stroke="var(--exam-stroke)" strokeWidth={2.5} strokeDasharray={isGhost ? '4 6' : p.dash} strokeLinecap="round" opacity={isGhost ? ghostOpacity(shape.props.confidence) : 1} style={{ '--exam-light-stroke': p.stroke, '--exam-dark-stroke': p.darkStroke } as React.CSSProperties} />
|
||
</svg>
|
||
<span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, top: -24, fontSize: 11, fontWeight: 900, textTransform: 'uppercase', letterSpacing: 0.6, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 5, color: p.stroke }}>
|
||
<span aria-hidden="true">{isGhost ? 'AI' : p.icon}</span>
|
||
{shape.props.label || p.label}
|
||
</span>
|
||
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ position: 'absolute', right: 8, top: -24, fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||
{flags.slice(0, 2).map((flag, index) => <span key={flag} className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, top: 10 + index * 22, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px' }}>{flag}</span>)}
|
||
</HTMLContainer>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px', display: 'inline-flex', alignItems: 'center', gap: 3 }} title={`${maxMarks} mark${maxMarks === 1 ? '' : 's'}`}>
|
||
<span aria-hidden="true" style={{ opacity: 0.7 }}>◆</span>{maxMarks}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<HTMLContainer id={shape.id} style={{ width: toDomPrecision(shape.props.w), height: toDomPrecision(shape.props.h), pointerEvents: 'all' }}>
|
||
<style>{shapeCss}</style>
|
||
<div
|
||
className={`exam-canvas-shape exam-canvas-shape--${kind}${isContainer ? ' exam-canvas-shape--container' : ''}`}
|
||
style={{
|
||
'--exam-light-stroke': stroke,
|
||
'--exam-light-fill': p.fill,
|
||
'--exam-dark-stroke': darkStroke,
|
||
'--exam-dark-fill': p.darkFill,
|
||
width: '100%', height: '100%', boxSizing: 'border-box', border: `${isContainer ? 2.5 : 1.5}px ${isContainer ? 'solid' : (isAiSuggestion ? 'dashed' : p.dash ? 'dashed' : 'solid')} var(--exam-stroke)`,
|
||
borderRadius: isContainer ? 14 : 10,
|
||
background: isContainer ? 'transparent' : 'var(--exam-fill)', opacity: isAiSuggestion ? ghostOpacity(shape.props.confidence) : 1, color: 'var(--exam-stroke)', fontFamily: 'Inter, system-ui, sans-serif',
|
||
display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between',
|
||
padding: 8, boxShadow: isContainer ? 'none' : '0 10px 22px rgba(15,23,42,0.10)', overflow: 'hidden', gap: 6,
|
||
} as React.CSSProperties}
|
||
aria-label={title}
|
||
title={title}
|
||
>
|
||
<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, maxWidth: 'calc(100% - 60px)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
<span aria-hidden="true">{isAiSuggestion ? 'AI' : (isContainer ? (depth === 0 ? '▣' : '▢') : p.icon)}</span>
|
||
{shape.props.label || p.label}
|
||
</span>
|
||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||
{marksPill(shape.props.maxMarks)}
|
||
{confidence && <span className="exam-canvas-shape__pill exam-canvas-shape__confidence" style={{ fontSize: 11, fontWeight: 900, borderRadius: 999, padding: '2px 7px' }}>{confidence}</span>}
|
||
{!confidence && !marksPill(shape.props.maxMarks) && shape.props.questionId && kind !== 'context' && <span className="exam-canvas-shape__pill" style={{ fontSize: 11, fontWeight: 800, borderRadius: 999, padding: '2px 7px' }}>Attached</span>}
|
||
</span>
|
||
{flags.length > 0 && <span className="exam-canvas-shape__pill exam-canvas-shape__flag" style={{ position: 'absolute', left: 8, bottom: 8, fontSize: 10, fontWeight: 900, borderRadius: 999, padding: '1px 6px', maxWidth: 'calc(100% - 16px)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{flags.slice(0, 2).join(' · ')}</span>}
|
||
{kind === 'response' && shape.props.responseForm && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.9 }}>{RESPONSE_FORM_LABEL[shape.props.responseForm] ?? shape.props.responseForm}</span>}
|
||
{/* Context region: a visible tether to its owning question + the figure kind. */}
|
||
{kind === 'context' && shape.props.linkLabel && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', left: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.95 }}>→ {shape.props.linkLabel}</span>}
|
||
{kind === 'context' && shape.props.contextType && shape.props.contextType !== 'generic' && <span className="exam-canvas-shape__pill" style={{ position: 'absolute', right: 8, bottom: 8, fontSize: 10, fontWeight: 800, borderRadius: 999, padding: '1px 6px', opacity: 0.9 }}>{shape.props.contextType}</span>}
|
||
</div>
|
||
</HTMLContainer>
|
||
)
|
||
}
|
||
|
||
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) => <rect width={toDomPrecision(s.props.w)} height={toDomPrecision(s.props.h)} />
|
||
|
||
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 ShapeUtil<ExamCanvasTLShape> {
|
||
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<ExamPdfPageTLShape | null>((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 <path d={`M 0 ${toDomPrecision(shape.props.h / 2)} L ${toDomPrecision(shape.props.w)} ${toDomPrecision(shape.props.h / 2)}`} /> }
|
||
}
|
||
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 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
|
||
}
|