Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
751ede8479 |
@@ -1,8 +1,6 @@
|
|||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { BaseBoxShapeTool, BaseBoxShapeUtil, Edge2d, HTMLContainer, ShapeUtil, T, TLBaseBoxShape, Vec, toDomPrecision } from '@tldraw/tldraw'
|
import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, 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 { ExamCanvasRegionKind, ExamCanvasShapeKind } from '../../../utils/exam-canvas/model'
|
||||||
|
|
||||||
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
|
||||||
@@ -67,28 +65,10 @@ const shapeCss = `
|
|||||||
[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__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); }
|
||||||
`
|
`
|
||||||
|
|
||||||
function renderBoundaryLine(shape: ExamCanvasTLShape) {
|
|
||||||
const p = canvasShapePalette.boundary
|
|
||||||
const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
|
|
||||||
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={p.dash} strokeLinecap="round" 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">{p.icon}</span>
|
|
||||||
{shape.props.label || p.label}
|
|
||||||
</span>
|
|
||||||
</HTMLContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderShape(shape: ExamCanvasTLShape) {
|
function renderShape(shape: ExamCanvasTLShape) {
|
||||||
const kind = shape.props.kind
|
const kind = shape.props.kind
|
||||||
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
|
||||||
const isBoundary = kind === 'boundary'
|
const isBoundary = kind === 'boundary'
|
||||||
if (isBoundary) return renderBoundaryLine(shape)
|
|
||||||
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' }}>
|
||||||
<style>{shapeCss}</style>
|
<style>{shapeCss}</style>
|
||||||
@@ -141,55 +121,7 @@ class PdfPageUtil extends BaseBoxShapeUtil<ExamPdfPageTLShape> {
|
|||||||
}
|
}
|
||||||
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
|
||||||
}
|
}
|
||||||
class BoundaryUtil extends ShapeUtil<ExamCanvasTLShape> {
|
class BoundaryUtil extends BaseBoxShapeUtil<ExamCanvasTLShape> { static override type = SHAPE_TYPES.boundary; static override props = sharedProps; override getDefaultProps(){ return defaultProps('boundary', 680, 8) }; override component(shape: ExamCanvasTLShape){ return renderShape(shape) }; override indicator(shape: ExamCanvasTLShape){ return ind(shape) } }
|
||||||
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) } }
|
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) } } }
|
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) } } }
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import type {
|
|||||||
ExamResponseArea,
|
ExamResponseArea,
|
||||||
ExamTemplate,
|
ExamTemplate,
|
||||||
ExamTemplateDetail,
|
ExamTemplateDetail,
|
||||||
ExamTemplateLayout,
|
|
||||||
MarkingBatch,
|
MarkingBatch,
|
||||||
MarkUpsertPayload,
|
MarkUpsertPayload,
|
||||||
Neo4jSyncResult,
|
Neo4jSyncResult,
|
||||||
@@ -65,10 +64,6 @@ function questionPayload(q: ExamQuestion, idMap?: Map<string, string>) {
|
|||||||
spec_ref: q.spec_ref,
|
spec_ref: q.spec_ref,
|
||||||
bounds: q.bounds ?? null,
|
bounds: q.bounds ?? null,
|
||||||
page: q.page ?? null,
|
page: q.page ?? null,
|
||||||
source: q.source ?? 'manual',
|
|
||||||
confirmed: q.confirmed ?? true,
|
|
||||||
confidence: q.confidence ?? null,
|
|
||||||
derivation: q.derivation ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,8 +79,6 @@ function responseAreaPayload(r: ExamResponseArea, idMap?: Map<string, string>, d
|
|||||||
source: r.source,
|
source: r.source,
|
||||||
confirmed: r.confirmed,
|
confirmed: r.confirmed,
|
||||||
confidence: r.confidence,
|
confidence: r.confidence,
|
||||||
mark_subtype: r.mark_subtype ?? null,
|
|
||||||
derivation: r.derivation ?? null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,26 +92,6 @@ function boundaryPayload(b: ExamBoundary, idMap?: Map<string, string>, duplicate
|
|||||||
bounds: b.bounds,
|
bounds: b.bounds,
|
||||||
source: b.source,
|
source: b.source,
|
||||||
confirmed: b.confirmed,
|
confirmed: b.confirmed,
|
||||||
confidence: b.confidence ?? null,
|
|
||||||
derivation: b.derivation ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function layoutPayload(layout: ExamTemplateLayout, duplicate = false) {
|
|
||||||
return {
|
|
||||||
id: duplicate ? newUuid() : layout.id,
|
|
||||||
page_index: layout.page_index,
|
|
||||||
role: layout.role ?? null,
|
|
||||||
margin_left: layout.margin_left ?? null,
|
|
||||||
margin_right: layout.margin_right ?? null,
|
|
||||||
margin_top: layout.margin_top ?? null,
|
|
||||||
margin_bottom: layout.margin_bottom ?? null,
|
|
||||||
margins_enabled: layout.margins_enabled ?? true,
|
|
||||||
source: layout.source ?? 'manual',
|
|
||||||
confirmed: layout.confirmed ?? true,
|
|
||||||
confidence: layout.confidence ?? null,
|
|
||||||
derivation: layout.derivation ?? null,
|
|
||||||
meta: layout.meta ?? {},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +113,6 @@ async function replaceTemplate(
|
|||||||
questions: detail.questions.map((q) => questionPayload(q, idMap)),
|
questions: detail.questions.map((q) => questionPayload(q, idMap)),
|
||||||
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
|
response_areas: detail.response_areas.map((r) => responseAreaPayload(r, idMap, duplicateIds)),
|
||||||
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
|
boundaries: detail.boundaries.map((b) => boundaryPayload(b, idMap, duplicateIds)),
|
||||||
layout: (detail.layout ?? []).map((layout) => layoutPayload(layout, duplicateIds)),
|
|
||||||
},
|
},
|
||||||
{ headers },
|
{ headers },
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-58
@@ -75,8 +75,6 @@ export interface UpdateTemplateMetaPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
/** Canvas children (used from S4-9 onward; defined here so the seam is complete). */
|
||||||
export type ExamTemplateSource = 'manual' | 'ai';
|
|
||||||
|
|
||||||
export interface ExamQuestion {
|
export interface ExamQuestion {
|
||||||
id: string;
|
id: string;
|
||||||
template_id: string;
|
template_id: string;
|
||||||
@@ -91,10 +89,6 @@ export interface ExamQuestion {
|
|||||||
spec_ref: string | null;
|
spec_ref: string | null;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
page?: number | null;
|
page?: number | null;
|
||||||
source: ExamTemplateSource;
|
|
||||||
confirmed: boolean;
|
|
||||||
confidence: number | null;
|
|
||||||
derivation: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExamResponseAreaKind =
|
export type ExamResponseAreaKind =
|
||||||
@@ -105,8 +99,6 @@ export type ExamResponseAreaKind =
|
|||||||
| 'reference'
|
| 'reference'
|
||||||
| 'furniture';
|
| 'furniture';
|
||||||
|
|
||||||
export type ExamMarkSubtype = 'part_marks' | 'question_total' | 'grader_box';
|
|
||||||
|
|
||||||
export interface ExamResponseArea {
|
export interface ExamResponseArea {
|
||||||
id: string;
|
id: string;
|
||||||
question_id: string;
|
question_id: string;
|
||||||
@@ -116,11 +108,9 @@ export interface ExamResponseArea {
|
|||||||
kind: ExamResponseAreaKind;
|
kind: ExamResponseAreaKind;
|
||||||
response_form: string | null;
|
response_form: string | null;
|
||||||
context_type?: string | null;
|
context_type?: string | null;
|
||||||
source: ExamTemplateSource;
|
source: 'manual' | 'ai';
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
confidence: number | null;
|
confidence: number | null;
|
||||||
mark_subtype?: ExamMarkSubtype | null;
|
|
||||||
derivation?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamBoundary {
|
export interface ExamBoundary {
|
||||||
@@ -131,36 +121,14 @@ export interface ExamBoundary {
|
|||||||
page_index: number;
|
page_index: number;
|
||||||
y: number;
|
y: number;
|
||||||
bounds: Record<string, number> | null;
|
bounds: Record<string, number> | null;
|
||||||
source: ExamTemplateSource;
|
source: 'manual' | 'ai';
|
||||||
confirmed: boolean;
|
confirmed: boolean;
|
||||||
confidence: number | null;
|
|
||||||
derivation: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExamTemplateLayout {
|
|
||||||
id: string;
|
|
||||||
template_id: string;
|
|
||||||
page_index: number;
|
|
||||||
role: string | null;
|
|
||||||
margin_left: number | null;
|
|
||||||
margin_right: number | null;
|
|
||||||
margin_top: number | null;
|
|
||||||
margin_bottom: number | null;
|
|
||||||
margins_enabled: boolean;
|
|
||||||
source: ExamTemplateSource;
|
|
||||||
confirmed: boolean;
|
|
||||||
confidence: number | null;
|
|
||||||
derivation: string | null;
|
|
||||||
meta: Record<string, unknown>;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExamTemplateDetail extends ExamTemplate {
|
export interface ExamTemplateDetail extends ExamTemplate {
|
||||||
questions: ExamQuestion[];
|
questions: ExamQuestion[];
|
||||||
response_areas: ExamResponseArea[];
|
response_areas: ExamResponseArea[];
|
||||||
boundaries: ExamBoundary[];
|
boundaries: ExamBoundary[];
|
||||||
layout: ExamTemplateLayout[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -184,10 +152,6 @@ export interface TemplateReplacePayload {
|
|||||||
spec_ref?: string | null;
|
spec_ref?: string | null;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
page?: number | null;
|
page?: number | null;
|
||||||
source?: ExamTemplateSource;
|
|
||||||
confirmed?: boolean;
|
|
||||||
confidence?: number | null;
|
|
||||||
derivation?: string | null;
|
|
||||||
}>;
|
}>;
|
||||||
response_areas: Array<{
|
response_areas: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -200,8 +164,6 @@ export interface TemplateReplacePayload {
|
|||||||
source?: 'manual' | 'ai';
|
source?: 'manual' | 'ai';
|
||||||
confirmed?: boolean;
|
confirmed?: boolean;
|
||||||
confidence?: number | null;
|
confidence?: number | null;
|
||||||
mark_subtype?: ExamMarkSubtype | null;
|
|
||||||
derivation?: string | null;
|
|
||||||
}>;
|
}>;
|
||||||
boundaries: Array<{
|
boundaries: Array<{
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -210,25 +172,8 @@ export interface TemplateReplacePayload {
|
|||||||
page_index: number;
|
page_index: number;
|
||||||
y: number;
|
y: number;
|
||||||
bounds?: Record<string, number> | null;
|
bounds?: Record<string, number> | null;
|
||||||
source?: ExamTemplateSource;
|
source?: 'manual' | 'ai';
|
||||||
confirmed?: boolean;
|
confirmed?: boolean;
|
||||||
confidence?: number | null;
|
|
||||||
derivation?: string | null;
|
|
||||||
}>;
|
|
||||||
layout?: Array<{
|
|
||||||
id?: string;
|
|
||||||
page_index: number;
|
|
||||||
role?: string | null;
|
|
||||||
margin_left?: number | null;
|
|
||||||
margin_right?: number | null;
|
|
||||||
margin_top?: number | null;
|
|
||||||
margin_bottom?: number | null;
|
|
||||||
margins_enabled?: boolean;
|
|
||||||
source?: ExamTemplateSource;
|
|
||||||
confirmed?: boolean;
|
|
||||||
confidence?: number | null;
|
|
||||||
derivation?: string | null;
|
|
||||||
meta?: Record<string, unknown>;
|
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { isUuid, pageForY, serializeCanvasShapes, shapesFromTemplate } from './m
|
|||||||
|
|
||||||
const template: ExamTemplateDetail = {
|
const template: ExamTemplateDetail = {
|
||||||
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
id: 'tpl-1', title: 'Physics', subject: 'Physics', exam_id: null, exam_code: null, source_file_id: null, page_count: 1,
|
||||||
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [], layout: [],
|
institute_id: 'inst', teacher_id: 'teacher', status: 'draft', created_at: 'now', updated_at: 'now', questions: [], response_areas: [], boundaries: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('exam setup canvas serialization', () => {
|
describe('exam setup canvas serialization', () => {
|
||||||
@@ -43,7 +43,6 @@ describe('exam setup canvas serialization', () => {
|
|||||||
], pages)
|
], pages)
|
||||||
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
expect(payload.questions.find((q) => !q.is_container)?.page).toBe(2)
|
||||||
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
expect(payload.boundaries.every((b) => b.page_index === 1)).toBe(true)
|
||||||
expect(payload.boundaries.every((b) => b.bounds?.x === 260 && b.bounds?.w === 780)).toBe(true)
|
|
||||||
expect(payload.response_areas[0].page).toBe(2)
|
expect(payload.response_areas[0].page).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,17 +50,42 @@ describe('exam setup canvas serialization', () => {
|
|||||||
const shapes = shapesFromTemplate({
|
const shapes = shapesFromTemplate({
|
||||||
...template,
|
...template,
|
||||||
questions: [
|
questions: [
|
||||||
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
{ id: 'q1', template_id: 'tpl-1', parent_id: null, label: 'Q1', order: 0, max_marks: 0, answer_type: null, mcq_options: null, mark_scheme: {}, is_container: true, spec_ref: null },
|
||||||
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
{ id: 'p1', template_id: 'tpl-1', parent_id: 'q1', label: 'Q1(a)', order: 0, max_marks: 2, answer_type: 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: { x: 1, y: 2, w: 3, h: 4 }, page: 1 },
|
||||||
],
|
],
|
||||||
response_areas: [
|
response_areas: [
|
||||||
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null, derivation: null },
|
{ id: 'r1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 10, y: 20, w: 30, h: 40 }, kind: 'response', response_form: 'lines', source: 'manual', confirmed: true, confidence: null },
|
||||||
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null, derivation: null },
|
{ id: 'f1', question_id: 'p1', template_id: 'tpl-1', page: 1, bounds: { x: 11, y: 21, w: 31, h: 41 }, kind: 'furniture', response_form: null, source: 'manual', confirmed: true, confidence: null },
|
||||||
],
|
],
|
||||||
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true, confidence: null, derivation: null }],
|
boundaries: [{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 0, y: 100, bounds: { x: 0, y: 100, w: 700, h: 8 }, source: 'manual', confirmed: true }],
|
||||||
})
|
})
|
||||||
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
expect(shapes.map((s) => s.kind).sort()).toEqual(['boundary', 'furniture', 'part', 'response'])
|
||||||
expect(shapes.find((s) => s.kind === 'boundary')).toMatchObject({ id: 'b1', x: 0, y: 100, w: 780, h: 8 })
|
|
||||||
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
|
expect(shapes.find((s) => s.kind === 'part')).toMatchObject({ id: 'p1', x: 1, y: 2, w: 3, h: 4 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('round-trips boundary y as page-local with page_index while rendering at full page width', () => {
|
||||||
|
const pages = [
|
||||||
|
{ pageNumber: 1, x: 0, y: 0, w: 780, h: 1000 },
|
||||||
|
{ pageNumber: 2, x: 0, y: 1000, w: 820, h: 1200 },
|
||||||
|
]
|
||||||
|
const shapes = shapesFromTemplate({
|
||||||
|
...template,
|
||||||
|
page_count: 2,
|
||||||
|
boundaries: [
|
||||||
|
{ id: 'b1', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 start', page_index: 1, y: 160, bounds: { x: 48, y: 160, w: 700, h: 8 }, source: 'manual', confirmed: true },
|
||||||
|
{ id: 'b2', template_id: 'tpl-1', question_id: 'q1', label: 'Q1 end', page_index: 1, y: 720, bounds: null, source: 'manual', confirmed: true },
|
||||||
|
],
|
||||||
|
}, pages)
|
||||||
|
|
||||||
|
expect(shapes[0]).toMatchObject({ kind: 'boundary', x: 0, y: 1160, w: 820, h: 8 })
|
||||||
|
expect(shapes[1]).toMatchObject({ kind: 'boundary', x: 0, y: 1720, w: 820, h: 8 })
|
||||||
|
|
||||||
|
const payload = serializeCanvasShapes(template, shapes, pages)
|
||||||
|
expect(payload.boundaries.map((b) => ({ page_index: b.page_index, y: b.y }))).toEqual([
|
||||||
|
{ page_index: 1, y: 160 },
|
||||||
|
{ page_index: 1, y: 720 },
|
||||||
|
])
|
||||||
|
expect(payload.boundaries.every((b) => b.bounds.w === 820)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface CanvasPageGeometry { pageNumber: number; x: number; y: number;
|
|||||||
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
export type ExamCanvasRegionKind = 'response' | 'context' | 'question_number' | 'mark_area' | 'reference' | 'furniture'
|
||||||
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
export type ExamCanvasShapeKind = 'boundary' | 'part' | ExamCanvasRegionKind
|
||||||
|
|
||||||
export interface CanvasBounds extends Record<string, number> { x: number; y: number; w: number; h: number }
|
export interface CanvasBounds { x: number; y: number; w: number; h: number }
|
||||||
|
|
||||||
export interface ExamCanvasShapeModel {
|
export interface ExamCanvasShapeModel {
|
||||||
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
|
/** Stable domain UUID persisted to Supabase. Do not reuse tldraw shape ids for new shapes. */
|
||||||
@@ -65,19 +65,10 @@ export function newDomainId(): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds & Record<string, number> {
|
function bounds(shape: Pick<ExamCanvasShapeModel, 'x' | 'y' | 'w' | 'h'>): CanvasBounds {
|
||||||
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
|
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h }
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageGeometry(pageNumber: number, pages?: CanvasPageGeometry[]): CanvasPageGeometry {
|
|
||||||
return pages?.find((page) => page.pageNumber === pageNumber) ?? { pageNumber, x: 0, y: pageTop(pageNumber, pages), w: PAGE_WIDTH, h: PAGE_HEIGHT }
|
|
||||||
}
|
|
||||||
|
|
||||||
function boundaryBounds(shape: Pick<ExamCanvasShapeModel, 'y' | 'h'>, pages?: CanvasPageGeometry[]): CanvasBounds & Record<string, number> {
|
|
||||||
const page = pageGeometry(pageForShape(shape, pages), pages)
|
|
||||||
return { x: page.x, y: shape.y, w: page.w, h: 8 }
|
|
||||||
}
|
|
||||||
|
|
||||||
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
|
function contains(outer: CanvasBounds, inner: CanvasBounds): boolean {
|
||||||
const ox2 = outer.x + outer.w
|
const ox2 = outer.x + outer.w
|
||||||
const oy2 = outer.y + outer.h
|
const oy2 = outer.y + outer.h
|
||||||
@@ -111,10 +102,10 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
const qNum = bands.length + 1
|
const qNum = bands.length + 1
|
||||||
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
|
const questionId = isUuid(top.questionId) ? top.questionId : isUuid(bottom.questionId) ? bottom.questionId : newDomainId()
|
||||||
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
|
const label = top.label?.replace(/\s+(start|end)$/i, '') || bottom.label?.replace(/\s+(start|end)$/i, '') || `Q${qNum}`
|
||||||
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {}, source: 'manual', confirmed: true, confidence: null, derivation: null })
|
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
|
||||||
bands.push({ questionId, top, bottom })
|
bands.push({ questionId, top, bottom })
|
||||||
for (const b of [top, bottom]) {
|
for (const b of [top, bottom]) {
|
||||||
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: boundaryBounds(b, pages), source: 'manual', confirmed: true, confidence: null, derivation: null })
|
boundaries.push({ id: isUuid(b.id) ? b.id : newDomainId(), question_id: questionId, label: b === top ? `${label} start` : `${label} end`, page_index: pageForShape(b, pages) - 1, y: b.y, bounds: bounds(b), source: 'manual', confirmed: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +114,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
const parentBand = bands.find((band) => bandContains(band.top, band.bottom, part))
|
||||||
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
|
const qid = isUuid(part.questionId) ? part.questionId : isUuid(part.id) ? part.id : newDomainId()
|
||||||
partQuestionIds.set(part.id, qid)
|
partQuestionIds.set(part.id, qid)
|
||||||
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages), source: 'manual', confirmed: true, confidence: null, derivation: null })
|
questions.push({ id: qid, parent_id: parentBand?.questionId ?? null, label: part.label || `Part ${index + 1}`, order: index, max_marks: Number(part.maxMarks ?? 0), answer_type: part.answerType ?? 'written', mcq_options: null, mark_scheme: {}, is_container: false, spec_ref: null, bounds: bounds(part), page: pageForShape(part, pages) })
|
||||||
})
|
})
|
||||||
|
|
||||||
const response_areas: TemplateReplacePayload['response_areas'] = []
|
const response_areas: TemplateReplacePayload['response_areas'] = []
|
||||||
@@ -133,29 +124,27 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
|
|||||||
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
const questionId = containingPart ? partQuestionIds.get(containingPart.id) : fallbackPart ? partQuestionIds.get(fallbackPart.id) : undefined
|
||||||
if (!questionId) continue
|
if (!questionId) continue
|
||||||
const kind = region.kind as ExamCanvasRegionKind
|
const kind = region.kind as ExamCanvasRegionKind
|
||||||
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null, mark_subtype: null, derivation: null })
|
response_areas.push({ id: isUuid(region.id) ? region.id : newDomainId(), question_id: questionId, page: pageForShape(region, pages), bounds: bounds(region), kind, response_form: kind === 'response' ? (region.responseForm ?? 'lines') : null, context_type: kind === 'context' ? (region.contextType ?? 'generic') : null, source: 'manual', confirmed: true, confidence: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries, layout: template.layout ?? [] }
|
return { meta: { title: template.title, subject: template.subject ?? undefined, page_count: template.page_count ?? undefined, status: template.status }, questions, response_areas, boundaries }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPageGeometry[]): ExamCanvasShapeModel[] {
|
||||||
const shapes: ExamCanvasShapeModel[] = []
|
const shapes: ExamCanvasShapeModel[] = []
|
||||||
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
const questions = new Map(detail.questions.map((q) => [q.id, q]))
|
||||||
for (const b of detail.boundaries ?? []) {
|
for (const b of detail.boundaries ?? []) {
|
||||||
const page = pageGeometry((b.page_index ?? 0) + 1, pages)
|
const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
|
||||||
// Boundary rows are y-lines. The old bounds rect is vestigial: keep y/domain ids,
|
shapes.push({ id: b.id, kind: 'boundary', x: Number(bb.x ?? 48), y: Number(bb.y ?? b.y), w: Number(bb.w ?? PAGE_WIDTH - 96), h: Number(bb.h ?? 8), label: b.label ?? undefined, questionId: b.question_id })
|
||||||
// but render and save a full rendered-page-width horizontal rule.
|
|
||||||
shapes.push({ id: b.id, kind: 'boundary', x: page.x, y: Number(b.y), w: page.w, h: 8, label: b.label ?? undefined, questionId: b.question_id })
|
|
||||||
}
|
}
|
||||||
for (const q of detail.questions ?? []) {
|
for (const q of detail.questions ?? []) {
|
||||||
if (q.is_container || !q.bounds) continue
|
if (q.is_container || !q.bounds) continue
|
||||||
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: (q.answer_type as ExamCanvasShapeModel['answerType']) ?? 'written', questionId: q.id })
|
shapes.push({ id: q.id, kind: 'part', x: Number(q.bounds.x ?? 80), y: Number(q.bounds.y ?? 120), w: Number(q.bounds.w ?? 420), h: Number(q.bounds.h ?? 180), label: q.label, maxMarks: q.max_marks, answerType: q.answer_type ?? 'written', questionId: q.id })
|
||||||
}
|
}
|
||||||
for (const r of detail.response_areas ?? []) {
|
for (const r of detail.response_areas ?? []) {
|
||||||
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
|
const bb = r.bounds ?? { x: 100, y: pageTop(r.page, pages) + 360, w: 360, h: 120 }
|
||||||
const q = questions.get(r.question_id)
|
const q = questions.get(r.question_id)
|
||||||
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: (r.response_form as ExamCanvasShapeModel['responseForm']) ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
|
shapes.push({ id: r.id, kind: r.kind, x: Number(bb.x ?? 100), y: Number(bb.y ?? pageTop(r.page, pages) + 360), w: Number(bb.w ?? 360), h: Number(bb.h ?? 120), label: q ? `→ ${q.label}` : r.kind, responseForm: r.response_form ?? undefined, contextType: r.context_type ?? undefined, questionId: r.question_id })
|
||||||
}
|
}
|
||||||
return shapes
|
return shapes
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user