diff --git a/src/pages/exam/setup/examCanvasShapes.tsx b/src/pages/exam/setup/examCanvasShapes.tsx
index a58b748..7c36bb3 100644
--- a/src/pages/exam/setup/examCanvasShapes.tsx
+++ b/src/pages/exam/setup/examCanvasShapes.tsx
@@ -1,6 +1,8 @@
import React from 'react'
-import { BaseBoxShapeTool, BaseBoxShapeUtil, HTMLContainer, T, TLBaseBoxShape, toDomPrecision } from '@tldraw/tldraw'
+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'
export const PDF_PAGE_SHAPE_TYPE = 'exam-pdf-page'
@@ -65,10 +67,28 @@ 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); }
`
+function renderBoundaryLine(shape: ExamCanvasTLShape) {
+ const p = canvasShapePalette.boundary
+ const lineY = Math.max(1, Math.min(shape.props.h - 1, shape.props.h / 2))
+ return (
+
+
+
+
+ {p.icon}
+ {shape.props.label || p.label}
+
+
+ )
+}
+
function renderShape(shape: ExamCanvasTLShape) {
const kind = shape.props.kind
const p = canvasShapePalette[kind] ?? canvasShapePalette.response
const isBoundary = kind === 'boundary'
+ if (isBoundary) return renderBoundaryLine(shape)
return (
@@ -121,7 +141,55 @@ class PdfPageUtil extends BaseBoxShapeUtil {
}
override indicator(shape: ExamPdfPageTLShape) { return ind(shape) }
}
-class BoundaryUtil extends BaseBoxShapeUtil { 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) } }
+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) } } }
diff --git a/src/utils/exam-canvas/model.test.ts b/src/utils/exam-canvas/model.test.ts
index 52b40b7..b8e3add 100644
--- a/src/utils/exam-canvas/model.test.ts
+++ b/src/utils/exam-canvas/model.test.ts
@@ -43,6 +43,7 @@ describe('exam setup canvas serialization', () => {
], pages)
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.bounds?.x === 260 && b.bounds?.w === 780)).toBe(true)
expect(payload.response_areas[0].page).toBe(2)
})
@@ -60,6 +61,7 @@ describe('exam setup canvas serialization', () => {
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.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 })
})
})
diff --git a/src/utils/exam-canvas/model.ts b/src/utils/exam-canvas/model.ts
index 22bdb54..b885161 100644
--- a/src/utils/exam-canvas/model.ts
+++ b/src/utils/exam-canvas/model.ts
@@ -65,10 +65,19 @@ export function newDomainId(): string {
})
}
-function bounds(shape: Pick): CanvasBounds {
+function bounds(shape: Pick): CanvasBounds & Record {
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, pages?: CanvasPageGeometry[]): CanvasBounds & Record {
+ 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 {
const ox2 = outer.x + outer.w
const oy2 = outer.y + outer.h
@@ -105,7 +114,7 @@ export function serializeCanvasShapes(template: ExamTemplateDetail, shapes: Exam
questions.push({ id: questionId, label, order: qNum - 1, max_marks: 0, is_container: true, mark_scheme: {} })
bands.push({ questionId, 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: bounds(b), source: 'manual', confirmed: true })
+ 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 })
}
}
@@ -134,8 +143,10 @@ export function shapesFromTemplate(detail: ExamTemplateDetail, pages?: CanvasPag
const shapes: ExamCanvasShapeModel[] = []
const questions = new Map(detail.questions.map((q) => [q.id, q]))
for (const b of detail.boundaries ?? []) {
- const bb = b.bounds ?? { x: 48, y: b.y, w: PAGE_WIDTH - 96, h: 8 }
- 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 })
+ const page = pageGeometry((b.page_index ?? 0) + 1, pages)
+ // Boundary rows are y-lines. The old bounds rect is vestigial: keep y/domain ids,
+ // 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 ?? []) {
if (q.is_container || !q.bounds) continue