spike(exam): tldraw 3.6.1 custom shape/tool proof (S4-9.spike)
Recovered from cc-worker WIP left uncommitted in the dev-centre clone (card t_fbe15cad, marked done but never committed/pushed). Adds ExamMarkerSpikePage + exam-marker-spike util/test proving custom box shape + tool + hit-testing on tldraw 3.6.1. Isolated on this branch (NOT master); reference for S4-9a. Includes incidental edits to S4-8 files made during the spike — review before reuse. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
adc7a2a05b
commit
e5f073eb91
@ -35,6 +35,7 @@ vi.mock('./pages/exam', () => ({ ExamDashboardPage: () => <div>Exam Marker</div>
|
||||
vi.mock('./pages/user/calendarPage', () => ({ default: () => <div>Calendar</div> }));
|
||||
vi.mock('./pages/user/settingsPage', () => ({ default: () => <div>Settings</div> }));
|
||||
vi.mock('./pages/tldraw/devPlayerPage', () => ({ default: () => <div>TLDraw Dev</div> }));
|
||||
vi.mock('./pages/tldraw/ExamMarkerSpikePage', () => ({ default: () => <div>Exam Marker Spike</div> }));
|
||||
vi.mock('./pages/tldraw/devPage', () => ({ default: () => <div>Dev</div> }));
|
||||
vi.mock('./pages/react-flow/teacherPlanner', () => ({ default: () => <div>Teacher Planner</div> }));
|
||||
vi.mock('./pages/morphicPage', () => ({ default: () => <div>Morphic</div> }));
|
||||
|
||||
@ -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 = () => {
|
||||
<Route path="/doc-intelligence/:fileId" element={<CCDocumentIntelligence />} />
|
||||
<Route path="/morphic" element={<MorphicPage />} />
|
||||
<Route path="/tldraw-dev" element={<TLDrawDevPage />} />
|
||||
<Route path="/exam-marker-spike" element={<ExamMarkerSpikePage />} />
|
||||
<Route path="/dev" element={<DevPage />} />
|
||||
<Route path="/dev/upload-test" element={<SimpleUploadTest />} />
|
||||
<Route path="/single-player" element={<SinglePlayerPage />} />
|
||||
|
||||
@ -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<File | null>(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.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={handleOpenCreate}>
|
||||
New template
|
||||
</Button>
|
||||
</Box>
|
||||
@ -134,7 +149,7 @@ const ExamDashboardPage: React.FC = () => {
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Create your first template to start mapping an exam paper.
|
||||
</Typography>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={handleOpenCreate}>
|
||||
New template
|
||||
</Button>
|
||||
</Paper>
|
||||
@ -144,8 +159,16 @@ const ExamDashboardPage: React.FC = () => {
|
||||
<Grid item xs={12} sm={6} md={4} key={t.id}>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{ p: 3, height: '100%', cursor: 'pointer', display: 'flex', flexDirection: 'column', gap: 1,
|
||||
transition: 'box-shadow 120ms', '&:hover': { boxShadow: 6 } }}
|
||||
sx={{
|
||||
p: 3,
|
||||
height: '100%',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
transition: 'box-shadow 120ms',
|
||||
'&:hover': { boxShadow: 6 },
|
||||
}}
|
||||
onClick={() => navigate(`/exam-marker/${t.id}/setup`)}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
@ -175,7 +198,7 @@ const ExamDashboardPage: React.FC = () => {
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Dialog open={createOpen} onClose={() => (saving ? null : setCreateOpen(false))} fullWidth maxWidth="sm">
|
||||
<Dialog open={createOpen} onClose={handleCloseCreate} fullWidth maxWidth="sm">
|
||||
<DialogTitle>New exam template</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
@ -193,10 +216,24 @@ const ExamDashboardPage: React.FC = () => {
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<Box>
|
||||
<Button variant="outlined" component="label">
|
||||
{sourcePdf ? 'Replace source PDF' : 'Attach source PDF'}
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
onChange={(e) => setSourcePdf(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</Button>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{sourcePdf ? sourcePdf.name : 'Optional: upload a PDF source for this template.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleCloseCreate} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} disabled={saving || !title.trim()}>
|
||||
{saving ? 'Creating…' : 'Create'}
|
||||
</Button>
|
||||
|
||||
170
src/pages/tldraw/ExamMarkerSpikePage.tsx
Normal file
170
src/pages/tldraw/ExamMarkerSpikePage.tsx
Normal file
@ -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<Editor | null>(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 (
|
||||
<div style={{ position: 'fixed', inset: 0, top: HEADER_HEIGHT, background: '#f8fafc' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 10,
|
||||
top: 16,
|
||||
left: 16,
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: '10px 12px',
|
||||
borderRadius: 16,
|
||||
background: 'rgba(15, 23, 42, 0.9)',
|
||||
color: 'white',
|
||||
boxShadow: '0 10px 30px rgba(15, 23, 42, 0.22)',
|
||||
maxWidth: 880,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gap: 4 }}>
|
||||
<strong style={{ fontSize: 14 }}>Exam-marker spike</strong>
|
||||
<span style={{ fontSize: 12, opacity: 0.9 }}>{status}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={insertSpike}
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: 'none',
|
||||
background: '#f59e0b',
|
||||
color: '#111827',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Insert image + marker box
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!editorRef.current) return
|
||||
const toolId = activateExamMarkerTool(editorRef.current)
|
||||
setStatus(`Activated tool ${toolId}. Drag on the canvas to create another marker box.`)
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
background: 'transparent',
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Activate box tool
|
||||
</button>
|
||||
<button
|
||||
onClick={focusBox}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: 10,
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
background: 'transparent',
|
||||
color: 'white',
|
||||
fontWeight: 700,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Focus marker
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Tldraw
|
||||
store={store}
|
||||
tools={spikeTools}
|
||||
shapeUtils={spikeShapeUtils}
|
||||
bindingUtils={spikeBindingUtils}
|
||||
components={uiComponents}
|
||||
overrides={uiOverrides}
|
||||
assetUrls={customAssets}
|
||||
autoFocus
|
||||
hideUi={false}
|
||||
inferDarkMode={false}
|
||||
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
|
||||
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
|
||||
maxImageDimension={Infinity}
|
||||
maxAssetSize={100 * 1024 * 1024}
|
||||
onMount={onMount}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -43,6 +43,24 @@ export const examRepository = {
|
||||
|
||||
async createTemplate(payload: CreateTemplatePayload): Promise<ExamTemplate> {
|
||||
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<ExamTemplate>(`${EXAM_BASE}/templates`, form, {
|
||||
headers,
|
||||
});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
const res = await axios.post<ExamTemplate>(`${EXAM_BASE}/templates`, payload, { headers });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
91
src/utils/tldraw/exam-marker-spike.test.ts
Normal file
91
src/utils/tldraw/exam-marker-spike.test.ts
Normal file
@ -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')
|
||||
})
|
||||
})
|
||||
197
src/utils/tldraw/exam-marker-spike.tsx
Normal file
197
src/utils/tldraw/exam-marker-spike.tsx
Normal file
@ -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<ExamMarkerBoxShape> {
|
||||
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 (
|
||||
<HTMLContainer
|
||||
id={shape.id}
|
||||
style={{
|
||||
width: toDomPrecision(shape.props.w),
|
||||
height: toDomPrecision(shape.props.h),
|
||||
border: '2px solid #b91c1c',
|
||||
borderRadius: 14,
|
||||
background: 'linear-gradient(180deg, rgba(255, 251, 235, 0.96), rgba(254, 242, 242, 0.96))',
|
||||
boxShadow: '0 12px 24px rgba(185, 28, 28, 0.18)',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
color: '#7f1d1d',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.8 }}>
|
||||
Exam marker spike
|
||||
</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.35 }}>
|
||||
This custom box shape uses the app's tldraw schema + BaseBoxShapeTool path.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start',
|
||||
background: 'rgba(185, 28, 28, 0.12)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Drag to resize · use the spike tool to create more
|
||||
</div>
|
||||
</div>
|
||||
</HTMLContainer>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="720" viewBox="0 0 960 720">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff"/>
|
||||
<stop offset="100%" stop-color="#f4f7fb"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="960" height="720" rx="32" fill="url(#g)" stroke="#cbd5e1" stroke-width="6"/>
|
||||
<rect x="72" y="56" width="816" height="88" rx="18" fill="#1f2937" opacity="0.92"/>
|
||||
<text x="116" y="108" fill="#fff" font-size="42" font-family="Inter, Arial, sans-serif" font-weight="700">${label}</text>
|
||||
<rect x="72" y="176" width="816" height="120" rx="16" fill="#eef2ff" stroke="#c7d2fe"/>
|
||||
<text x="106" y="224" fill="#1e3a8a" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Question 1</text>
|
||||
<text x="106" y="266" fill="#334155" font-size="24" font-family="Inter, Arial, sans-serif">Show your working and annotate the image.</text>
|
||||
<rect x="72" y="332" width="816" height="250" rx="16" fill="#fff7ed" stroke="#fed7aa"/>
|
||||
<text x="106" y="384" fill="#9a3412" font-size="30" font-family="Inter, Arial, sans-serif" font-weight="700">Answer space</text>
|
||||
<rect x="106" y="420" width="700" height="18" rx="9" fill="#fdba74" opacity="0.75"/>
|
||||
<rect x="106" y="470" width="640" height="18" rx="9" fill="#fdba74" opacity="0.55"/>
|
||||
<rect x="106" y="520" width="570" height="18" rx="9" fill="#fdba74" opacity="0.4"/>
|
||||
<text x="106" y="644" fill="#475569" font-size="20" font-family="Inter, Arial, sans-serif">Inserted as a built-in tldraw image shape.</text>
|
||||
</svg>
|
||||
`.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<ExamMarkerBoxShape>({
|
||||
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,
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user