Initial commit

This commit is contained in:
2025-07-11 13:21:49 +00:00
commit 8a7ab3ac24
262 changed files with 28219 additions and 0 deletions
@@ -0,0 +1,92 @@
import { TLShapeId } from '@tldraw/tldraw';
export interface AnnotationData {
studentIndex?: number; // undefined for exam/markscheme annotations
pageIndex: number;
shapeId: TLShapeId;
bounds: {
x: number;
y: number;
width: number;
height: number;
};
}
export class AnnotationManager {
private examAnnotations: Set<TLShapeId> = new Set();
private markSchemeAnnotations: Set<TLShapeId> = new Set();
private studentAnnotations: Map<number, Set<TLShapeId>> = new Map();
private annotationData: Map<TLShapeId, AnnotationData> = new Map();
addAnnotation(shapeId: TLShapeId, data: AnnotationData) {
this.annotationData.set(shapeId, data);
if (data.studentIndex !== undefined) {
// Student response annotation
let studentSet = this.studentAnnotations.get(data.studentIndex);
if (!studentSet) {
studentSet = new Set();
this.studentAnnotations.set(data.studentIndex, studentSet);
}
studentSet.add(shapeId);
} else {
// Exam or mark scheme annotation
if (data.pageIndex < 0) {
this.examAnnotations.add(shapeId);
} else {
this.markSchemeAnnotations.add(shapeId);
}
}
}
removeAnnotation(shapeId: TLShapeId) {
const data = this.annotationData.get(shapeId);
if (!data) return;
if (data.studentIndex !== undefined) {
const studentSet = this.studentAnnotations.get(data.studentIndex);
studentSet?.delete(shapeId);
} else {
if (data.pageIndex < 0) {
this.examAnnotations.delete(shapeId);
} else {
this.markSchemeAnnotations.delete(shapeId);
}
}
this.annotationData.delete(shapeId);
}
getAnnotationsForStudent(studentIndex: number): TLShapeId[] {
return Array.from(this.studentAnnotations.get(studentIndex) || []);
}
getAnnotationsForExam(): TLShapeId[] {
return Array.from(this.examAnnotations);
}
getAnnotationsForMarkScheme(): TLShapeId[] {
return Array.from(this.markSchemeAnnotations);
}
getAnnotationData(shapeId: TLShapeId): AnnotationData | undefined {
return this.annotationData.get(shapeId);
}
clear() {
this.examAnnotations.clear();
this.markSchemeAnnotations.clear();
this.studentAnnotations.clear();
this.annotationData.clear();
}
// Future transcription support
addTranscriptionToAnnotation(shapeId: TLShapeId) {
const data = this.annotationData.get(shapeId);
if (data) {
this.annotationData.set(shapeId, {
...data
});
}
}
}
@@ -0,0 +1,108 @@
import React, { useState } from 'react';
import { Box } from '@mui/material';
import 'tldraw/tldraw.css';
import { CCPdfEditor } from './CCPdfEditor';
import { CCPdfPicker } from './CCPdfPicker';
import { ExamPdfState } from './types';
import './cc-exam-marker.css';
import { HEADER_HEIGHT } from '../../Layout';
import { CCPanel } from '../../../utils/tldraw/ui-overrides/components/CCPanel';
export const CCExamMarker = () => {
const [state, setState] = useState<ExamPdfState>({ phase: 'pick' });
const [view, setView] = useState<'exam-and-markscheme' | 'student-responses'>('exam-and-markscheme');
const [currentStudentIndex, setCurrentStudentIndex] = useState(0);
const [isExpanded, setIsExpanded] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const handleViewChange = (newView: 'exam-and-markscheme' | 'student-responses') => {
setView(newView);
};
const handleNextStudent = () => {
if (state.phase === 'edit' && 'studentResponses' in state && 'examPaper' in state) {
const totalStudents = Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length);
if (currentStudentIndex < totalStudents - 1) {
setCurrentStudentIndex(prev => prev + 1);
}
}
};
const handlePreviousStudent = () => {
if (currentStudentIndex > 0) {
setCurrentStudentIndex(prev => prev - 1);
}
};
return (
<Box sx={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.default',
color: 'text.primary',
}}>
{state.phase === 'pick' ? (
<CCPdfPicker
onOpenPdfs={(pdfs) =>
setState({
phase: 'edit',
examPaper: pdfs.examPaper,
markScheme: pdfs.markScheme,
studentResponses: pdfs.studentResponses,
})
}
/>
) : (
<Box sx={{ flex: 1, position: 'relative' }}>
<Box sx={{
position: 'absolute',
inset: 0,
bgcolor: 'background.paper',
}}>
<CCPdfEditor
examPaper={state.examPaper}
markScheme={state.markScheme}
studentResponses={state.studentResponses}
currentView={view}
currentStudentIndex={currentStudentIndex}
onEditorMount={(editor) => {
if (!editor) return null;
const examMarkerProps = {
editor,
currentView: view,
onViewChange: handleViewChange,
currentStudentIndex,
totalStudents: Math.floor(state.studentResponses.pages.length / state.examPaper.pages.length),
onPreviousStudent: handlePreviousStudent,
onNextStudent: handleNextStudent,
getCurrentPdf: () => {
if (!editor) return null;
const currentPageId = editor.getCurrentPageId();
if (currentPageId.includes('exam-page')) {
return state.examPaper;
} else if (currentPageId.includes('mark-scheme-page')) {
return state.markScheme;
} else if (currentPageId.includes('student-response')) {
return state.studentResponses;
}
return null;
},
};
return <CCPanel
examMarkerProps={examMarkerProps}
isExpanded={isExpanded}
isPinned={isPinned}
onExpandedChange={setIsExpanded}
onPinnedChange={setIsPinned}
/>;
}}
/>
</Box>
</Box>
)}
</Box>
);
};
@@ -0,0 +1,113 @@
import { PDFDocument } from 'pdf-lib';
import { useState } from 'react';
import { Editor, exportToBlob } from '@tldraw/tldraw';
import { Button } from '@mui/material';
import { Pdf } from './types';
interface CCExportPdfButtonProps {
editor: Editor;
pdf: Pdf;
}
export function CCExportPdfButton({ editor, pdf }: CCExportPdfButtonProps) {
const [exportProgress, setExportProgress] = useState<number | null>(null);
const exportPdf = async (
editor: Editor,
{ name, source, pages }: Pdf,
onProgress: (progress: number) => void
) => {
const totalThings = pages.length * 2 + 2;
let progressCount = 0;
const tickProgress = () => {
progressCount++;
onProgress(progressCount / totalThings);
};
const pdf = await PDFDocument.load(source);
tickProgress();
const pdfPages = pdf.getPages();
if (pdfPages.length !== pages.length) {
throw new Error('PDF page count mismatch');
}
const pageShapeIds = new Set(pages.map((page) => page.shapeId));
const allIds = Array.from(editor.getCurrentPageShapeIds()).filter(
(id) => !pageShapeIds.has(id)
);
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const pdfPage = pdfPages[i];
const {bounds} = page;
const shapesInBounds = allIds.filter((id) => {
const shapePageBounds = editor.getShapePageBounds(id);
if (!shapePageBounds) return false;
return shapePageBounds.collides(bounds);
});
if (shapesInBounds.length === 0) {
tickProgress();
tickProgress();
continue;
}
const exportedPng = await exportToBlob({
editor,
ids: allIds,
format: 'png',
opts: { background: false, bounds: page.bounds, padding: 0, scale: 1 },
});
tickProgress();
pdfPage.drawImage(await pdf.embedPng(await exportedPng.arrayBuffer()), {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight(),
});
tickProgress();
}
const url = URL.createObjectURL(
new Blob([await pdf.save()], { type: 'application/pdf' })
);
tickProgress();
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
};
return (
<Button
className="CCExportPdfButton"
variant="contained"
color="primary"
onClick={async () => {
setExportProgress(0);
try {
await exportPdf(editor, pdf, setExportProgress);
} finally {
setExportProgress(null);
}
}}
sx={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 1000,
}}
>
{exportProgress
? `Exporting... ${Math.round(exportProgress * 100)}%`
: 'Export PDF'}
</Button>
);
}
@@ -0,0 +1,385 @@
import React from 'react';
import { Box } from '@mui/material';
import { Editor, TLPageId, Box as TLBox } from '@tldraw/editor';
import { Tldraw } from '@tldraw/tldraw';
import { useCallback, useEffect, useState, useRef } from 'react';
import { ExamPdfs } from './types';
import { AnnotationManager, AnnotationData } from './AnnotationManager';
import { logger } from '../../../debugConfig';
const PAGE_SPACING = 32; // Same spacing as the example
interface CCPdfEditorProps extends ExamPdfs {
currentView: 'exam-and-markscheme' | 'student-responses';
currentStudentIndex: number;
onEditorMount: (editor: Editor) => React.ReactNode;
}
export function CCPdfEditor({
examPaper,
markScheme,
studentResponses,
currentView,
currentStudentIndex,
onEditorMount,
}: CCPdfEditorProps) {
const [editor, setEditor] = useState<Editor | null>(null);
const [pagesInitialized, setPagesInitialized] = useState(false);
const annotationManager = useRef(new AnnotationManager());
const handleMount = useCallback((editor: Editor) => {
setEditor(editor);
onEditorMount(editor);
// Subscribe to shape changes
editor.on('change', () => {
const shapes = editor.getCurrentPageShapeIds();
logger.debug('cc-exam-marker', '🔄 Shape change detected', {
totalShapes: shapes.size,
currentPage: editor.getCurrentPageId()
});
shapes.forEach(shapeId => {
const shape = editor.getShape(shapeId);
if (shape && !shape.isLocked) { // Only track non-locked shapes (annotations)
const bounds = editor.getShapePageBounds(shapeId);
if (bounds) {
const currentPageId = editor.getCurrentPageId();
let annotationData: AnnotationData;
if (currentPageId.includes('student-response')) {
const studentIndex = parseInt(currentPageId.split('-').pop() || '0', 10);
// Find which page this annotation belongs to by checking collision with page bounds
const pageShapes = Array.from(shapes).filter(id => {
const s = editor.getShape(id);
return s?.isLocked; // Locked shapes are our PDF pages
});
let pageIndex = -1; // Default to -1 if no collision found
for (let i = 0; i < pageShapes.length; i++) {
const pageShape = editor.getShape(pageShapes[i]);
if (!pageShape) continue;
const pageBounds = editor.getShapePageBounds(pageShapes[i]);
if (!pageBounds) continue;
// Check if the annotation's center point is within the page bounds
const annotationCenter = {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2
};
if (annotationCenter.x >= pageBounds.x &&
annotationCenter.x <= pageBounds.x + pageBounds.width &&
annotationCenter.y >= pageBounds.y &&
annotationCenter.y <= pageBounds.y + pageBounds.height) {
pageIndex = i;
break;
}
}
logger.debug('cc-exam-marker', '📏 Calculated page index', {
shapeId,
shapeBounds: bounds,
pageIndex,
studentIndex
});
annotationData = {
studentIndex,
pageIndex,
shapeId,
bounds: {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
}
};
} else {
// For exam/mark scheme, use current page type as index
const pageIndex = currentPageId.includes('exam') ? -1 : 1;
annotationData = {
pageIndex,
shapeId,
bounds: {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
}
};
}
logger.debug('cc-exam-marker', '📝 Adding/updating annotation', {
shapeId,
annotationData,
currentPage: currentPageId
});
annotationManager.current.addAnnotation(shapeId, annotationData);
}
}
});
});
}, [onEditorMount]);
// Initial setup effect - runs only once when editor is mounted
useEffect(() => {
if (!editor || pagesInitialized) return;
const setupExamAndMarkScheme = async () => {
const examPageId = 'page:exam-page' as TLPageId;
const markSchemePageId = 'page:mark-scheme-page' as TLPageId;
// Calculate vertical layout for exam pages
let top = 0;
let widest = 0;
const examPages = examPaper.pages.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = top;
top += height + PAGE_SPACING;
widest = Math.max(widest, width);
return { ...page, top: currentTop, width, height };
});
// Center pages horizontally
examPages.forEach(page => {
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
});
// Create exam paper page
editor.createPage({
id: examPageId,
name: 'Exam Paper',
});
editor.setCurrentPage(examPageId);
// Create assets and shapes for exam pages
examPages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
// Similar process for mark scheme pages
let markSchemeTop = 0;
const markSchemePages = markScheme.pages.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = markSchemeTop;
markSchemeTop += height + PAGE_SPACING;
return {
...page,
bounds: new TLBox((widest - width) / 2, currentTop, width, height)
};
});
// Create mark scheme page
editor.createPage({
id: markSchemePageId,
name: 'Mark Scheme',
});
editor.setCurrentPage(markSchemePageId);
// Create assets and shapes for mark scheme pages
markSchemePages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
// Go back to exam page
editor.setCurrentPage(examPageId);
};
const setupStudentResponses = async () => {
const pagesPerStudent = examPaper.pages.length;
const totalStudents = Math.floor(studentResponses.pages.length / pagesPerStudent);
for (let studentIndex = 0; studentIndex < totalStudents; studentIndex++) {
const startPage = studentIndex * pagesPerStudent;
const endPage = startPage + pagesPerStudent;
const studentPageId = `page:student-response-${studentIndex}` as TLPageId;
// Calculate vertical layout
let top = 0;
let widest = 0;
const studentPages = studentResponses.pages
.slice(startPage, endPage)
.map(page => {
const width = page.bounds.width;
const height = page.bounds.height;
const currentTop = top;
top += height + PAGE_SPACING;
widest = Math.max(widest, width);
return { ...page, top: currentTop, width, height };
});
// Center pages horizontally
studentPages.forEach(page => {
page.bounds = new TLBox((widest - page.width) / 2, page.top, page.width, page.height);
});
// Create page for this student
editor.createPage({
id: studentPageId,
name: `Student ${studentIndex + 1}`,
});
editor.setCurrentPage(studentPageId);
// Create assets and shapes
studentPages.forEach((page) => {
editor.createAssets([{
id: page.assetId,
typeName: 'asset',
type: 'image',
props: {
w: page.bounds.width,
h: page.bounds.height,
name: 'PDF Page',
src: page.src,
isAnimated: false,
mimeType: 'image/png',
},
meta: {},
}]);
editor.createShape({
id: page.shapeId,
type: 'image',
x: page.bounds.x,
y: page.bounds.y,
props: {
w: page.bounds.width,
h: page.bounds.height,
assetId: page.assetId,
},
isLocked: true,
});
});
}
};
// Initial setup of all pages
const setup = async () => {
await setupExamAndMarkScheme();
await setupStudentResponses();
setPagesInitialized(true);
};
setup();
}, [editor, pagesInitialized, examPaper, markScheme, studentResponses]);
// Effect to handle view changes and navigation
useEffect(() => {
if (!editor || !pagesInitialized) return;
// Switch to appropriate page based on current view
const targetPageId = currentView === 'exam-and-markscheme'
? ('page:exam-page' as TLPageId)
: (`page:student-response-${currentStudentIndex}` as TLPageId);
logger.debug('cc-exam-marker', '🔄 Switching view', {
currentView,
currentStudentIndex,
targetPageId
});
editor.setCurrentPage(targetPageId);
// Update camera constraints for current page
const currentPageBounds = Array.from(editor.getCurrentPageShapeIds()).reduce(
(acc: TLBox | null, shapeId) => {
const bounds = editor.getShapePageBounds(shapeId);
return bounds ? (acc ? acc.union(bounds) : bounds) : acc;
},
null as TLBox | null
);
if (currentPageBounds) {
const isMobile = editor.getViewportScreenBounds().width < 840;
editor.setCameraOptions({
constraints: {
bounds: currentPageBounds,
padding: { x: isMobile ? 16 : 164, y: 64 },
origin: { x: 0.5, y: 0 },
initialZoom: 'fit-x-100',
baseZoom: 'default',
behavior: 'contain',
},
});
editor.setCamera(editor.getCamera(), { reset: true });
}
}, [editor, pagesInitialized, currentView, currentStudentIndex]);
// Expose annotationManager to parent through onEditorMount
useEffect(() => {
if (editor) {
onEditorMount(editor);
// @ts-expect-error - Adding custom property to editor for CCExamMarkerPanel access
editor.annotationManager = annotationManager.current;
}
}, [editor, onEditorMount]);
return (
<Box sx={{ width: '100%', height: '100%', position: 'relative' }}>
<Tldraw
onMount={handleMount}
components={{
InFrontOfTheCanvas: () => onEditorMount(editor!)
}}
/>
</Box>
);
}
@@ -0,0 +1,210 @@
import { useState } from 'react';
import { Box, Button, Stack, Typography } from '@mui/material';
import { AssetRecordType, Box as TLBox, createShapeId } from '@tldraw/editor';
import { ExamPdfs, Pdf, PdfPage } from './types';
interface CCPdfPickerProps {
onOpenPdfs: (pdfs: ExamPdfs) => void;
}
const pageSpacing = 32;
export function CCPdfPicker({ onOpenPdfs }: CCPdfPickerProps) {
const [isLoading, setIsLoading] = useState(false);
const [selectedPdfs, setSelectedPdfs] = useState<Partial<ExamPdfs>>({});
async function loadPdf(name: string, source: ArrayBuffer): Promise<Pdf> {
const PdfJS = await import('pdfjs-dist');
PdfJS.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url
).toString();
const pdf = await PdfJS.getDocument(source.slice()).promise;
const pages: PdfPage[] = [];
const canvas = window.document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Failed to create canvas context');
const visualScale = 1.5;
const scale = window.devicePixelRatio;
let top = 0;
let widest = 0;
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const viewport = page.getViewport({ scale: scale * visualScale });
canvas.width = viewport.width;
canvas.height = viewport.height;
const renderContext = {
canvasContext: context,
viewport,
};
await page.render(renderContext).promise;
const width = viewport.width / scale;
const height = viewport.height / scale;
pages.push({
src: canvas.toDataURL(),
bounds: new TLBox(0, top, width, height),
assetId: AssetRecordType.createId(),
shapeId: createShapeId(),
});
top += height + pageSpacing;
widest = Math.max(widest, width);
}
canvas.width = 0;
canvas.height = 0;
for (const page of pages) {
page.bounds.x = (widest - page.bounds.width) / 2;
}
return {
name,
pages,
source,
};
}
const handleFileSelect = async (type: keyof ExamPdfs, file: File) => {
setIsLoading(true);
try {
const pdf = await loadPdf(file.name, await file.arrayBuffer());
// Validate student responses page count
if (type === 'studentResponses' && selectedPdfs.examPaper) {
const examPageCount = selectedPdfs.examPaper.pages.length;
if (pdf.pages.length % examPageCount !== 0) {
alert(`Student responses PDF must have a number of pages that is a multiple of the exam paper's ${examPageCount} pages.\n\nStudent responses PDF has ${pdf.pages.length} pages, which is not a multiple of ${examPageCount}.`);
return;
}
}
setSelectedPdfs((prev) => ({ ...prev, [type]: pdf }));
} catch (error) {
console.error('Error loading PDF:', error);
alert('Error loading PDF (mismatch between responses and exam paper). Please try again.');
} finally {
setIsLoading(false);
}
};
const createFileInput = (type: keyof ExamPdfs) => {
const input = window.document.createElement('input');
input.type = 'file';
input.accept = 'application/pdf';
input.addEventListener('change', async (e) => {
const fileList = (e.target as HTMLInputElement).files;
if (!fileList || fileList.length === 0) return;
await handleFileSelect(type, fileList[0]);
});
input.click();
};
const allPdfsSelected = () => {
return (
selectedPdfs.examPaper &&
selectedPdfs.markScheme &&
selectedPdfs.studentResponses
);
};
if (isLoading) {
return (
<Box className="CCPdfPicker" sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}>
<Typography>Loading...</Typography>
</Box>
);
}
return (
<Box className="CCPdfPicker" sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}>
<Stack
spacing={4}
alignItems="center"
sx={{
maxWidth: '800px',
width: '100%',
p: 3
}}
>
<Typography variant="h5">Select PDF Files</Typography>
<Stack
direction="row"
sx={{
width: '100%',
justifyContent: 'center',
gap: 4 // Using MUI's spacing unit (1 unit = 8px, so 4 = 32px)
}}
>
<Button
variant={selectedPdfs.examPaper ? 'contained' : 'outlined'}
onClick={() => createFileInput('examPaper')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.examPaper ? '✓ Exam Paper' : 'Select Exam Paper'}
</Button>
<Button
variant={selectedPdfs.markScheme ? 'contained' : 'outlined'}
onClick={() => createFileInput('markScheme')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.markScheme ? '✓ Mark Scheme' : 'Select Mark Scheme'}
</Button>
<Button
variant={selectedPdfs.studentResponses ? 'contained' : 'outlined'}
onClick={() => createFileInput('studentResponses')}
sx={{
minWidth: '180px',
height: '48px'
}}
>
{selectedPdfs.studentResponses ? '✓ Student Responses' : 'Select Student Responses'}
</Button>
</Stack>
{allPdfsSelected() && (
<Box sx={{ mt: 4, width: '100%', display: 'flex', justifyContent: 'center' }}>
<Button
variant="contained"
color="primary"
onClick={() => onOpenPdfs(selectedPdfs as ExamPdfs)}
sx={{
minWidth: '180px',
height: '48px'
}}
>
Continue
</Button>
</Box>
)}
</Stack>
</Box>
);
}
@@ -0,0 +1,61 @@
.CCExamMarker {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
}
.CCExamMarker .CCPdfPicker {
position: absolute;
inset: 1rem;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
flex-direction: column;
gap: 1rem;
}
.CCExamMarker .CCPdfBgRenderer {
position: absolute;
pointer-events: none;
}
.CCExamMarker .CCPdfBgRenderer img {
position: absolute;
}
.CCExamMarker .PageOverlayScreen-screen {
pointer-events: none;
z-index: -1;
fill: var(--color-background);
fill-opacity: 0.8;
stroke: none;
}
.CCExamMarker .PageOverlayScreen-outline {
position: absolute;
pointer-events: none;
z-index: -1;
box-shadow: var(--shadow-2);
}
.CCExamMarker .CCExportPdfButton {
font: inherit;
background: var(--color-primary);
border: none;
color: var(--color-selected-contrast);
font-size: 1rem;
padding: 0.5rem 1rem;
border-radius: 6px;
margin: 6px;
margin-bottom: 0;
pointer-events: all;
z-index: var(--layer-panels);
border: 2px solid var(--color-background);
cursor: pointer;
}
.CCExamMarker .CCExportPdfButton:hover {
filter: brightness(1.1);
}
+44
View File
@@ -0,0 +1,44 @@
import { Box, TLAssetId, TLShapeId } from '@tldraw/tldraw';
export interface PdfPage {
src: string;
bounds: Box;
assetId: TLAssetId;
shapeId: TLShapeId;
}
export interface Pdf {
name: string;
pages: PdfPage[];
source: string | ArrayBuffer;
}
export interface ExamPdfs {
examPaper: Pdf;
markScheme: Pdf;
studentResponses: Pdf;
}
export type ExamPdfState =
| {
phase: 'pick';
}
| {
phase: 'edit';
examPaper: Pdf;
markScheme: Pdf;
studentResponses: Pdf;
};
export interface StudentResponse {
studentId: string;
pageStart: number;
pageEnd: number;
}
export interface ExamMetadata {
totalPages: number;
pagesPerStudent: number;
totalStudents: number;
studentResponses: StudentResponse[];
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { logger } from '../../debugConfig';
interface LaunchParams {
files: FileSystemFileHandle[];
}
interface LaunchQueue {
setConsumer(callback: (params: LaunchParams) => Promise<void>): void;
}
interface WindowWithLaunchQueue extends Window {
launchQueue: LaunchQueue;
}
const ShareHandler = () => {
const navigate = useNavigate();
useEffect(() => {
const processSharedData = async () => {
try {
// Handle files shared through Web Share Target API
if ('launchQueue' in window) {
(window as WindowWithLaunchQueue).launchQueue.setConsumer(async (launchParams: LaunchParams) => {
if (!launchParams.files.length) {
logger.debug('share-handler', 'No files shared');
return;
}
for (const fileHandle of launchParams.files) {
const file = await fileHandle.getFile();
logger.info('share-handler', 'Processing shared file', {
name: file.name,
type: file.type,
size: file.size
});
// Navigate to single player with the shared file
// You might want to modify this based on your needs
navigate('/single-player', {
state: {
sharedFile: file
}
});
}
});
}
// Handle URL parameters for text/url sharing
const urlParams = new URLSearchParams(window.location.search);
const title = urlParams.get('title');
const text = urlParams.get('text');
const url = urlParams.get('url');
if (title || text || url) {
logger.info('share-handler', 'Processing shared content', {
title,
text,
url
});
// Navigate to single player with the shared content
navigate('/single-player', {
state: {
sharedContent: { title, text, url }
}
});
}
} catch (error) {
logger.error('share-handler', 'Error processing shared content', { error });
}
};
processSharedData();
}, [navigate]);
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh'
}}>
Processing shared content...
</div>
);
};
export default ShareHandler;
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import { Tldraw } from '@tldraw/tldraw';
import '@tldraw/tldraw/tldraw.css';
const TLDrawCanvas: React.FC = () => {
return (
<div style={{ width: '100%', height: '100%' }}>
<Tldraw persistenceKey="classroom-copilot-landing-page" />
</div>
);
};
export default TLDrawCanvas;
+469
View File
@@ -0,0 +1,469 @@
import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
} from '@tldraw/tldraw';
import { useAuth } from '../../contexts/AuthContext';
import { useTLDraw } from '../../contexts/TLDrawContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
// Tldraw utils
import { customAssets } from '../../utils/tldraw/assets';
import { devEmbeds } from '../../utils/tldraw/embeds';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { devTools } from '../../utils/tldraw/tools';
import { customSchema } from '../../utils/tldraw/schemas';
// Layout
import { HEADER_HEIGHT } from '../Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
interface EventFilter {
type: 'all' | 'ui' | 'store' | 'canvas';
subType?: string;
enabled: boolean;
}
interface EventFilters {
mode: 'all' | 'specific';
filters: {
[key: string]: EventFilter;
};
}
const EventMonitoringControls: React.FC<{
filters: EventFilters;
setFilters: (filters: EventFilters) => void;
onClear: () => void;
}> = ({ filters, setFilters, onClear }) => {
const handleModeChange = (mode: 'all' | 'specific') => {
setFilters({ ...filters, mode });
};
const handleFilterChange = (key: string, enabled: boolean) => {
setFilters({
...filters,
filters: {
...filters.filters,
[key]: { ...filters.filters[key], enabled }
}
});
};
return (
<div className="event-monitor-controls">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div className="mode-selector">
<label>
<input
type="radio"
checked={filters.mode === 'all'}
onChange={() => handleModeChange('all')}
/>
Monitor All Events
</label>
<label>
<input
type="radio"
checked={filters.mode === 'specific'}
onChange={() => handleModeChange('specific')}
/>
Monitor Specific Events
</label>
</div>
<button
onClick={onClear}
style={{
padding: '4px 8px',
backgroundColor: '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Clear Logs
</button>
</div>
{filters.mode === 'specific' && (
<div className="specific-filters">
<select
onChange={(e) => handleFilterChange(e.target.value, true)}
value=""
>
<option value="" disabled>Add Event Filter</option>
<optgroup label="UI Events">
<option value="ui-selection">Selection Changes</option>
<option value="ui-tool">Tool Changes</option>
<option value="ui-viewport">Viewport Changes</option>
</optgroup>
<optgroup label="Store Events">
<option value="store-shapes">Shape Updates</option>
<option value="store-bindings">Binding Updates</option>
<option value="store-assets">Asset Updates</option>
</optgroup>
<optgroup label="Canvas Events">
<option value="canvas-pointer">Pointer Events</option>
<option value="canvas-camera">Camera Events</option>
<option value="canvas-selection">Selection Events</option>
</optgroup>
</select>
<div className="active-filters">
{Object.entries(filters.filters)
.filter(([, filter]) => filter.enabled)
.map(([key]) => (
<div key={key} className="filter-tag">
{key}
<button onClick={() => handleFilterChange(key, false)}>×</button>
</div>
))}
</div>
</div>
)}
</div>
);
};
const MAX_EVENTS = 100; // Limit visible events to last 100
const EventDisplay: React.FC<{ events: Array<{ type: string; data: string; timestamp: string }> }> =
({ events }) => {
const scrollContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
}
}, [events]);
// Only show the last MAX_EVENTS events
const visibleEvents = useMemo(() =>
events.slice(-MAX_EVENTS),
[events]
);
return (
<div
ref={scrollContainerRef}
className="event-display"
style={{
flex: 1,
padding: 8,
background: '#ddd',
borderLeft: 'solid 2px #333',
fontFamily: 'monospace',
fontSize: 12,
overflow: 'auto',
scrollBehavior: 'smooth',
}}
>
{visibleEvents.length === MAX_EVENTS && (
<div style={{
padding: '4px 8px',
marginBottom: 8,
backgroundColor: '#fff3cd',
color: '#856404',
borderRadius: 4,
fontSize: 11,
}}>
Showing last {MAX_EVENTS} events only
</div>
)}
{visibleEvents.map((event, i) => (
<pre
key={event.timestamp + i}
style={{
borderBottom: '1px solid #000',
marginBottom: 0,
paddingBottom: '12px',
backgroundColor: getEventTypeColor(event.type),
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
}}
>
<span className="event-timestamp">{event.timestamp}</span>
<span className="event-type">[{event.type}]</span>
{event.data}
</pre>
))}
</div>
);
};
const getEventTypeColor = (type: string): string => {
switch (type) {
case 'ui':
return '#e8f0fe'; // Light blue
case 'store':
return '#fef3e8'; // Light orange
case 'canvas':
return '#f0fee8'; // Light green
default:
return 'transparent';
}
};
export default function DevPage() {
const { user } = useAuth();
const navigate = useNavigate();
const { tldrawPreferences, initializePreferences, setTldrawPreferences } = useTLDraw();
const [events, setEvents] = useState<Array<{ type: 'ui' | 'store' | 'canvas'; data: string; timestamp: string; }>>([]);
const [eventFilters, setEventFilters] = useState<EventFilters>({ mode: 'all', filters: {} });
const [logPanelWidth, setLogPanelWidth] = useState(30); // Width in percentage
const editorRef = useRef<Editor | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const handleDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDraggingRef.current = true;
document.body.style.cursor = 'col-resize';
const handleDragMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
const windowWidth = window.innerWidth;
const newWidth = (e.clientX / windowWidth) * 100;
// Limit the range to between 20% and 80%
const clampedWidth = Math.min(Math.max(newWidth, 20), 80);
setLogPanelWidth(100 - clampedWidth);
};
const handleDragUp = () => {
isDraggingRef.current = false;
document.body.style.cursor = 'default';
window.removeEventListener('mousemove', handleDragMove);
window.removeEventListener('mouseup', handleDragUp);
};
window.addEventListener('mousemove', handleDragMove);
window.addEventListener('mouseup', handleDragUp);
}, []);
// Create tldraw user
const tldrawUser = useTldrawUser({
userPreferences: {
id: user?.id ?? 'dev-user',
name: user?.display_name ?? 'Unknown User',
color: tldrawPreferences?.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
// Create store
const store = useMemo(() => localStoreService.getStore({
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils
}), []);
// Initialize preferences when user is available
useEffect(() => {
if (user?.id && !tldrawPreferences) {
logger.debug('dev-page', '🔄 Initializing preferences for user', { userId: user.id });
initializePreferences(user.id);
}
}, [user?.id, tldrawPreferences, initializePreferences]);
// Redirect if no user
useEffect(() => {
if (!user) {
logger.info('dev-page', '🚪 Redirecting to home - no user logged in');
navigate('/');
}
}, [user, navigate]);
const shouldCaptureEvent = useCallback((type: 'ui' | 'store' | 'canvas', data: string) => {
if (eventFilters.mode === 'all') return true;
// Check specific filters
return Object.entries(eventFilters.filters)
.some(([key, filter]) => {
if (!filter.enabled) return false;
const [filterType, filterSubType] = key.split('-');
if (filterType !== type) return false;
// Match specific event subtypes
switch (filterType) {
case 'ui':
return data.includes(filterSubType);
case 'store':
return data.includes(`"type":"${filterSubType}"`);
case 'canvas':
return data.includes(`Canvas Event: ${filterSubType}`);
default:
return false;
}
});
}, [eventFilters]);
const addEvent = useCallback((type: 'ui' | 'store' | 'canvas', data: string) => {
if (!shouldCaptureEvent(type, data)) return;
setEvents(prevEvents => {
const newEvents = [...prevEvents, {
type,
data,
timestamp: new Date().toISOString()
}];
// Keep last 2 * MAX_EVENTS in state to allow some scrollback
return newEvents.slice(-(MAX_EVENTS * 2));
});
}, [shouldCaptureEvent]);
const handleUiEvent = useCallback((name: string, data: unknown) => {
const eventString = `UI Event: ${name} ${JSON.stringify(data)}`;
addEvent('ui', eventString);
console.log(eventString);
}, [addEvent]);
const handleCanvasEvent = useCallback((editor: Editor) => {
logger.trace('dev-page', '🎨 Canvas editor mounted');
editor.on('change', () => {
const camera = editor.getCamera();
logger.trace('dev-page', '🎥 Camera changed', { camera });
addEvent('canvas', `Canvas Event: camera ${JSON.stringify(camera)}`);
});
editor.on('change', () => {
const selectedIds = editor.getSelectedShapeIds();
if (selectedIds.length > 0) {
logger.trace('dev-page', '🔍 Selection changed', { selectedIds });
addEvent('canvas', `Canvas Event: selection ${JSON.stringify(selectedIds)}`);
}
});
editor.on('event', (info) => {
if (info.type === 'pointer') {
const point = editor.inputs.currentPagePoint;
logger.trace('dev-page', '👆 Pointer event', { point });
addEvent('canvas', `Canvas Event: pointer ${JSON.stringify(point)}`);
}
});
}, [addEvent]);
useEffect(() => {
if (store) {
const cleanupFn = store.listen((info) => {
const eventString = `Store Event: ${info.source} ${JSON.stringify(info.changes)}`;
addEvent('store', eventString);
console.log(eventString);
});
return () => cleanupFn();
}
}, [store, addEvent]);
useEffect(() => {
if (scrollContainerRef.current) {
const scrollContainer = scrollContainerRef.current;
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}, [events]);
const clearEvents = useCallback(() => {
setEvents([]);
}, []);
if (!user) {
logger.info('dev-page', '🚫 Rendering null - no user');
return null;
}
return (
<div style={{
display: 'flex',
width: '100%',
height: `calc(100vh - ${HEADER_HEIGHT}px)`,
position: 'fixed',
top: `${HEADER_HEIGHT}px`
}}>
<div style={{
width: `${100 - logPanelWidth}%`,
height: '100%',
position: "absolute",
left: 0,
overflow: 'hidden'
}}>
<Tldraw
user={tldrawUser}
store={store}
onMount={(editor) => {
editorRef.current = editor;
handleCanvasEvent(editor);
logger.info('system', '🎨 Tldraw mounted', {
editorId: editor.store.id
});
}}
onUiEvent={handleUiEvent}
tools={devTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
embeds={devEmbeds}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
/>
</div>
<div
style={{
width: '5px',
height: '100%',
position: 'absolute',
left: `${100 - logPanelWidth}%`,
transform: 'translateX(-50%)',
cursor: 'col-resize',
backgroundColor: 'transparent',
zIndex: 1000,
}}
onMouseDown={handleDragStart}
>
<div style={{
width: '1px',
height: '100%',
backgroundColor: '#333',
margin: '0 auto',
}} />
</div>
<div
style={{
width: `${logPanelWidth}%`,
height: '100%',
position: 'absolute',
right: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<EventMonitoringControls
filters={eventFilters}
setFilters={setEventFilters}
onClear={clearEvents}
/>
<EventDisplay events={events} />
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useMemo, useRef } from 'react';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
TLAnyShapeUtilConstructor
} from '@tldraw/tldraw';
// App context
import { useTLDraw } from '../../contexts/TLDrawContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
import { PresentationService } from '../../services/tldraw/presentationService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
import { customAssets } from '../../utils/tldraw/assets';
import { devEmbeds } from '../../utils/tldraw/embeds';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { devTools } from '../../utils/tldraw/tools';
import { customSchema } from '../../utils/tldraw/schemas';
// Layout
import { HEADER_HEIGHT } from '../../pages/Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
const devUserId = 'dev-user';
export default function TLDrawDevPage() {
// 1. All context hooks first
const {
tldrawPreferences,
initializePreferences,
presentationMode,
setTldrawPreferences
} = useTLDraw();
// 2. All refs
const editorRef = useRef<Editor | null>(null);
// 4. All memos
const tldrawUser = useTldrawUser({
userPreferences: {
id: devUserId,
name: 'Dev User',
color: tldrawPreferences?.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
const store = useMemo(() => localStoreService.getStore({
schema: customSchema,
shapeUtils: [...allShapeUtils] as TLAnyShapeUtilConstructor[],
bindingUtils: allBindingUtils
}), []);
// Initialize preferences when user is available
useEffect(() => {
if (!tldrawPreferences) {
logger.debug('single-player-page', '🔄 Initializing preferences');
initializePreferences(devUserId);
}
}, [tldrawPreferences, initializePreferences]);
// Load initial data when user node is available
useEffect(() => {
if (!tldrawUser) {
return;
}
}, [tldrawUser, store]);
// Handle presentation mode
useEffect(() => {
if (presentationMode && editorRef.current) {
logger.info('presentation', '🔄 Presentation mode changed', {
presentationMode,
editorExists: !!editorRef.current
});
const editor = editorRef.current;
const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode();
return () => {
logger.info('presentation', '🧹 Cleaning up presentation mode');
presentationService.stopPresentationMode();
cleanup();
};
}
}, [presentationMode]);
// Modify the render logic to use presentationMode
const uiOverrides = getUiOverrides(presentationMode);
const uiComponents = getUiComponents(presentationMode);
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<Tldraw
user={tldrawUser}
store={store}
tools={devTools}
shapeUtils={allShapeUtils as TLAnyShapeUtilConstructor[]}
bindingUtils={allBindingUtils}
components={uiComponents}
overrides={uiOverrides}
embeds={devEmbeds}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
onMount={(editor) => {
logger.info('system', '🎨 Tldraw mounted', {
editorId: editor.store.id,
presentationMode
});
editorRef.current = editor;
}}
/>
</div>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useRef, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORTED_IMAGE_TYPES,
DEFAULT_SUPPORT_VIDEO_TYPES,
} from '@tldraw/tldraw';
import { useSync } from '@tldraw/sync';
// App context
import { useAuth } from '../../contexts/AuthContext';
import { useTLDraw } from '../../contexts/TLDrawContext';
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
// Tldraw services
import { multiplayerOptions } from '../../services/tldraw/optionsService';
import { PresentationService } from '../../services/tldraw/presentationService';
import { createSyncConnectionOptions, handleExternalAsset } from '../../services/tldraw/syncService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
import { customAssets } from '../../utils/tldraw/assets';
import { multiplayerTools } from '../../utils/tldraw/tools';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { customSchema } from '../../utils/tldraw/schemas';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { multiplayerEmbeds } from '../../utils/tldraw/embeds';
// Layout
import { HEADER_HEIGHT } from '../../pages/Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
const SYNC_WORKER_URL = import.meta.env.VITE_FRONTEND_SITE_URL.startsWith('http')
? `${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`
: `https://${import.meta.env.VITE_FRONTEND_SITE_URL}/tldraw`;
export default function TldrawMultiUser() {
const { user } = useAuth();
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
const {
tldrawPreferences,
setTldrawPreferences,
initializePreferences,
presentationMode
} = useTLDraw();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const editorRef = useRef<Editor | null>(null);
// Get room ID from URL params
const roomId = searchParams.get('room') || 'multiplayer';
// Memoize user information to ensure consistency
const userInfo = useMemo(() => ({
id: user?.id ?? '',
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`
}), [user?.id, user?.display_name, user?.email, tldrawPreferences?.color]);
// Create editor user with memoization
const editorUser = useTldrawUser({
userPreferences: {
id: userInfo.id,
name: userInfo.name,
color: userInfo.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
const connectionOptions = useMemo(() => createSyncConnectionOptions({
userId: userInfo.id,
displayName: userInfo.name,
color: userInfo.color,
roomId,
baseUrl: SYNC_WORKER_URL
}), [userInfo, roomId]);
const store = useSync({
...connectionOptions,
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils,
userInfo: {
id: userInfo.id,
name: userInfo.name,
color: userInfo.color
}
});
// Log connection status changes
useEffect(() => {
logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, {
status: store.status,
connectionOptions
});
}, [store.status, connectionOptions]);
// Effect for initializing preferences
useEffect(() => {
if (user?.id && !tldrawPreferences) {
logger.info('multiplayer-page', '🔄 Initializing preferences');
initializePreferences(user.id);
}
}, [user?.id, tldrawPreferences, initializePreferences]);
// Effect for redirecting if user is not authenticated
useEffect(() => {
if (!user) {
navigate('/');
}
}, [user, navigate]);
// Effect for presentation mode
useEffect(() => {
if (presentationMode && editorRef.current) {
const editor = editorRef.current;
const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode();
return () => {
presentationService.stopPresentationMode();
cleanup();
};
}
}, [presentationMode]);
// Memoize UI overrides and components
const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]);
const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]);
// Render conditionally to avoid unnecessary rerenders
if (!user) {
return null;
}
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.1)'
}}>
<div style={{
padding: '20px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}>
{isInstituteLoading ? 'Loading institute data...' : `Connecting to room: ${roomId}...`}
</div>
</div>
);
}
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
<Tldraw
user={editorUser}
store={store.store}
onMount={(editor) => {
editorRef.current = editor;
editor.registerExternalAssetHandler('url', async ({ url }: { url: string }) => {
return handleExternalAsset(SYNC_WORKER_URL, url);
});
}}
options={multiplayerOptions}
embeds={multiplayerEmbeds}
tools={multiplayerTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
overrides={uiOverrides}
components={uiComponents}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
/>
</div>
);
}
+514
View File
@@ -0,0 +1,514 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useLocation } from 'react-router';
import {
Tldraw,
Editor,
useTldrawUser,
DEFAULT_SUPPORT_VIDEO_TYPES,
DEFAULT_SUPPORTED_IMAGE_TYPES,
TLStore,
TLStoreWithStatus
} from '@tldraw/tldraw';
import { useTLDraw } from '../../contexts/TLDrawContext';
import { useUser } from '../../contexts/UserContext';
// Tldraw services
import { localStoreService } from '../../services/tldraw/localStoreService';
import { PresentationService } from '../../services/tldraw/presentationService';
import { UserNeoDBService } from '../../services/graph/userNeoDBService';
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
// Tldraw utils
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
import { customAssets } from '../../utils/tldraw/assets';
import { singlePlayerTools } from '../../utils/tldraw/tools';
import { allShapeUtils } from '../../utils/tldraw/shapes';
import { allBindingUtils } from '../../utils/tldraw/bindings';
import { singlePlayerEmbeds } from '../../utils/tldraw/embeds';
import { customSchema } from '../../utils/tldraw/schemas';
// Navigation
import { useNavigationStore } from '../../stores/navigationStore';
// Layout
import { HEADER_HEIGHT } from '../../pages/Layout';
// Styles
import '../../utils/tldraw/tldraw.css';
// App debug
import { logger } from '../../debugConfig';
import { CircularProgress, Alert, Snackbar } from '@mui/material';
import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles';
import { NodeData } from '../../types/graph-shape';
import { NavigationNode } from '../../types/navigation';
interface LoadingState {
status: 'ready' | 'loading' | 'error';
error: string;
}
export default function SinglePlayerPage() {
// Context hooks with initialization states
const { user, loading: userLoading } = useUser();
const {
tldrawPreferences,
initializePreferences,
presentationMode,
setTldrawPreferences
} = useTLDraw();
const routerNavigate = useNavigate();
const location = useLocation();
// Navigation store
const { context } = useNavigationStore();
// Refs
const editorRef = useRef<Editor | null>(null);
const snapshotServiceRef = useRef<NavigationSnapshotService | null>(null);
// State
const [loadingState, setLoadingState] = useState<LoadingState>({
status: 'ready',
error: ''
});
const [isInitialLoad, setIsInitialLoad] = useState(true);
const [isEditorReady, setIsEditorReady] = useState(false);
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
// TLDraw user preferences
const tldrawUser = useTldrawUser({
userPreferences: {
id: user?.id ?? '',
name: user?.display_name,
color: tldrawPreferences?.color,
locale: tldrawPreferences?.locale,
colorScheme: tldrawPreferences?.colorScheme,
animationSpeed: tldrawPreferences?.animationSpeed,
isSnapMode: tldrawPreferences?.isSnapMode
},
setUserPreferences: setTldrawPreferences
});
// Initialize store
useEffect(() => {
if (!isEditorReady) {
logger.debug('single-player-page', '⏳ Waiting for editor to be ready');
return;
}
if (!user) {
logger.debug('single-player-page', '⏳ Waiting for user data');
return;
}
if (!editorRef.current) {
logger.debug('single-player-page', '⏳ Waiting for editor ref');
return;
}
logger.info('single-player-page', '🔄 Starting store initialization', {
isEditorReady,
hasUser: !!user,
userType: user.user_type,
username: user.username
});
const initializeStoreAndSnapshot = async () => {
try {
setLoadingState({ status: 'loading', error: '' });
// 1. Create store
logger.debug('single-player-page', '🔄 Creating TLStore');
const newStore = localStoreService.getStore({
schema: customSchema,
shapeUtils: allShapeUtils,
bindingUtils: allBindingUtils
});
logger.debug('single-player-page', '✅ TLStore created');
// 2. Initialize snapshot service
const snapshotService = new NavigationSnapshotService(newStore);
snapshotServiceRef.current = snapshotService;
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
// 3. Load initial snapshot if we have a node
if (context.node) {
logger.debug('single-player-page', '📥 Loading snapshot from database', {
dbName: user.user_db_name,
tldraw_snapshot: context.node.tldraw_snapshot,
user_type: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
context.node.tldraw_snapshot,
user.user_db_name,
newStore,
setLoadingState
);
logger.debug('single-player-page', '✅ Snapshot loaded from database');
} else {
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
}
// 4. Set up auto-save
newStore.listen(() => {
if (snapshotServiceRef.current && context.node) {
logger.debug('single-player-page', '💾 Auto-saving changes');
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
logger.error('single-player-page', '❌ Auto-save failed', error);
});
}
});
// 5. Update store state
setStore(newStore);
setLoadingState({ status: 'ready', error: '' });
logger.info('single-player-page', '✅ Store initialization complete');
// 6. Handle cleanup
return () => {
logger.debug('single-player-page', '🧹 Starting cleanup');
if (snapshotServiceRef.current) {
snapshotServiceRef.current.forceSaveCurrentNode().catch(error => {
logger.error('single-player-page', '❌ Final save failed', error);
});
snapshotServiceRef.current.clearCurrentNode();
snapshotServiceRef.current = null;
}
newStore.dispose();
setStore(undefined);
logger.debug('single-player-page', '🧹 Cleanup complete');
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store';
logger.error('single-player-page', '❌ Store initialization failed', error);
setLoadingState({ status: 'error', error: errorMessage });
return undefined;
}
};
initializeStoreAndSnapshot();
}, [isEditorReady, user, context.node, editorRef.current]);
// Handle initial node placement
useEffect(() => {
const placeInitialNode = async () => {
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
return;
}
try {
setLoadingState({ status: 'loading', error: '' });
// Center the node
const nodeData = await loadNodeData(context.node);
await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData);
setIsInitialLoad(false);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('single-player-page', '❌ Failed to place initial node', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to place initial node'
});
}
};
placeInitialNode();
}, [context.node, store, isInitialLoad]);
// Handle navigation changes
useEffect(() => {
const handleNodeChange = async () => {
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) {
return;
}
// We can safely assert these types because we've checked for null above
const editor = editorRef.current as Editor;
const snapshotService = snapshotServiceRef.current;
const currentNode = context.node;
try {
setLoadingState({ status: 'loading', error: '' });
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: currentNode.id,
tldraw_snapshot: currentNode.tldraw_snapshot,
isInitialLoad
});
// Get the previous node from navigation history
const previousNode = context.history.currentIndex > 0
? context.history.nodes[context.history.currentIndex - 1]
: null;
// Handle navigation in snapshot service
await snapshotService.handleNavigationStart(previousNode, currentNode);
// Center the node on canvas
const nodeData = await loadNodeData(currentNode);
await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData);
setLoadingState({ status: 'ready', error: '' });
} catch (error) {
logger.error('single-player-page', '❌ Failed to load node data', error);
setLoadingState({
status: 'error',
error: error instanceof Error ? error.message : 'Failed to load node data'
});
}
};
handleNodeChange();
}, [context.node?.id, context.history, store]);
// Initialize preferences when user is available
useEffect(() => {
if (user?.id && !tldrawPreferences) {
logger.debug('single-player-page', '🔄 Initializing preferences for user', { userId: user.id });
initializePreferences(user.id);
}
}, [user?.id, tldrawPreferences, initializePreferences]);
// Redirect if no user or incorrect role
useEffect(() => {
if (!user || user.user_type !== 'admin') {
logger.info('single-player-page', '🚪 Redirecting to home - no user or incorrect role', {
hasUser: !!user,
userType: user?.user_type
});
routerNavigate('/', { replace: true });
}
}, [user, routerNavigate]);
// Handle presentation mode
useEffect(() => {
if (presentationMode && editorRef.current) {
logger.info('presentation', '🔄 Presentation mode changed', {
presentationMode,
editorExists: !!editorRef.current
});
const editor = editorRef.current;
const presentationService = new PresentationService(editor);
const cleanup = presentationService.startPresentationMode();
return () => {
logger.info('presentation', '🧹 Cleaning up presentation mode');
presentationService.stopPresentationMode();
cleanup();
};
}
}, [presentationMode]);
// Handle shared content
useEffect(() => {
const handleSharedContent = async () => {
if (!editorRef.current || !location.state) {
return;
}
const editor = editorRef.current;
const { sharedFile, sharedContent } = location.state as {
sharedFile?: File;
sharedContent?: {
title?: string;
text?: string;
url?: string;
};
};
if (sharedFile) {
logger.info('single-player-page', '📤 Processing shared file', {
name: sharedFile.name,
type: sharedFile.type
});
try {
// Handle different file types
if (sharedFile.type.startsWith('image/')) {
const imageUrl = URL.createObjectURL(sharedFile);
await editor.createShape({
type: 'image',
props: {
url: imageUrl,
w: 320,
h: 240,
name: sharedFile.name
}
});
URL.revokeObjectURL(imageUrl);
} else if (sharedFile.type === 'application/pdf') {
// Handle PDF (you might want to implement PDF handling)
logger.info('single-player-page', '📄 PDF handling not implemented yet');
} else if (sharedFile.type === 'text/plain') {
const text = await sharedFile.text();
editor.createShape({
type: 'text',
props: { text }
});
}
} catch (error) {
logger.error('single-player-page', '❌ Error processing shared file', { error });
}
}
if (sharedContent) {
logger.info('single-player-page', '📤 Processing shared content', { sharedContent });
const { title, text, url } = sharedContent;
let contentText = '';
if (title) {
contentText += `${title}\n`;
}
if (text) {
contentText += `${text}\n`;
}
if (url) {
contentText += url;
}
if (contentText) {
editor.createShape({
type: 'text',
props: { text: contentText }
});
}
}
};
handleSharedContent();
}, [location.state]);
// Modify the render logic to use presentationMode
const uiOverrides = getUiOverrides(presentationMode);
const uiComponents = getUiComponents(presentationMode);
// Show loading state if user context is still loading
if (userLoading) {
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--color-background)'
}}>
<CircularProgress />
</div>
);
}
return (
<div style={{
position: 'fixed',
inset: 0,
top: `${HEADER_HEIGHT}px`,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
{/* Loading overlay - show when loading or contexts not initialized */}
{(loadingState.status === 'loading' || !store) && (
<div style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.8)',
zIndex: 1000,
}}>
<CircularProgress />
</div>
)}
{/* Error snackbar */}
<Snackbar
open={loadingState.status === 'error'}
autoHideDuration={6000}
onClose={() => setLoadingState({ status: 'ready', error: '' })}
>
<Alert severity="error" onClose={() => setLoadingState({ status: 'ready', error: '' })}>
{loadingState.error}
</Alert>
</Snackbar>
<Tldraw
user={tldrawUser}
store={store}
tools={singlePlayerTools}
shapeUtils={allShapeUtils}
bindingUtils={allBindingUtils}
components={uiComponents}
overrides={uiOverrides}
embeds={singlePlayerEmbeds}
assetUrls={customAssets}
autoFocus={true}
hideUi={false}
inferDarkMode={false}
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
maxImageDimension={Infinity}
maxAssetSize={100 * 1024 * 1024}
renderDebugMenuItems={() => []}
onMount={(editor) => {
logger.info('single-player-page', '🎨 Starting Tldraw mount');
try {
if (!editor) {
logger.error('single-player-page', '❌ Editor is null in onMount');
return;
}
editorRef.current = editor;
logger.debug('single-player-page', '✅ Editor ref set');
setIsEditorReady(true);
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
editorId: editor.store.id,
presentationMode,
isEditorReady: true
});
} catch (error) {
logger.error('single-player-page', '❌ Error in onMount', error);
}
}}
/>
</div>
);
}
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
// 1. Always fetch fresh data
const dbName = UserNeoDBService.getNodeDatabaseName(node);
const fetchedData = await UserNeoDBService.fetchNodeData(node.id, dbName);
if (!fetchedData?.node_data) {
throw new Error('Failed to fetch node data');
}
// 2. Process the data into the correct shape
const theme = getThemeFromLabel(node.type);
return {
...fetchedData.node_data,
title: fetchedData.node_data.title || node.label,
w: 500,
h: 350,
state: {
parentId: null,
isPageChild: true,
hasChildren: null,
bindings: null
},
headerColor: theme.headerColor,
backgroundColor: theme.backgroundColor,
isLocked: false,
__primarylabel__: node.type,
unique_id: node.id,
tldraw_snapshot: node.tldraw_snapshot
};
};