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, createTLStore, } from '@tldraw/tldraw'; import { useTLDraw } from '../../contexts/TLDrawContext'; import { useAuth } from '../../contexts/AuthContext'; import { useUser } from '../../contexts/UserContext'; // Tldraw services import { localStoreService } from '../../services/tldraw/localStoreService'; import { PresentationService } from '../../services/tldraw/presentationService'; 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 { ErrorBoundary } from '../../components/ErrorBoundary'; interface LoadingState { status: 'ready' | 'loading' | 'error'; error: string; } export default function SinglePlayerPage() { // Context hooks with initialization states const { profile: user, loading: userLoading } = useUser(); const { accessToken } = useAuth(); const { context, setAuthInfo, switchContext } = useNavigationStore(); const { tldrawPreferences, initializePreferences, presentationMode, setTldrawPreferences } = useTLDraw(); const routerNavigate = useNavigate(); const location = useLocation(); // Refs const editorRef = useRef(null); const snapshotServiceRef = useRef(null); // State const [loadingState, setLoadingState] = useState({ status: 'ready', error: '' }); const storeRef = useRef(null); const [storeReady, setStoreReady] = useState(false); // 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 }); // Orchestrated context.node lifecycle โ€” replaces 3 separate effects const [canvasPhase, setCanvasPhase] = useState< 'idle' | 'store-init' | 'snapshot-loading' | 'node-placing' | 'ready' | 'navigating' | 'error' >('idle'); const phaseError = useRef(''); useEffect(() => { if (!user) return; let cancelled = false; const run = async () => { try { setCanvasPhase('store-init'); // Create a fresh store directly โ€” bypassing singleton to avoid disposed-store reuse issues const newStore = createTLStore({ schema: customSchema, shapeUtils: allShapeUtils, bindingUtils: allBindingUtils, }); const snapSvc = new NavigationSnapshotService(newStore, editorRef.current || undefined); if (accessToken) snapSvc.setAccessToken(accessToken); snapshotServiceRef.current = snapSvc; if (context.node) { setCanvasPhase('snapshot-loading'); const path = context.node.node_storage_path; if (path) { await NavigationSnapshotService.loadNodeSnapshotFromDatabase( path, accessToken || '', newStore, setLoadingState, undefined, editorRef.current || undefined ); snapSvc.setCurrentNodePath(path); } } if (cancelled) { newStore.dispose(); return; } // After snapshot loading (which may overwrite or clear store records), // ensure TLINSTANCE + page + cameras exist so tldraw never crashes on empty/corrupt store (newStore as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable(); let debounce: ReturnType | null = null; newStore.listen(() => { if (!snapshotServiceRef.current?.getCurrentNodePath()) return; if (debounce) clearTimeout(debounce); debounce = setTimeout(async () => { await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {}); }, 2000); }); storeRef.current = newStore; setStoreReady(true); setCanvasPhase('ready'); } catch (e) { phaseError.current = e instanceof Error ? e.message : 'Init failed'; setCanvasPhase('error'); } }; run(); return () => { cancelled = true; snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {}); snapshotServiceRef.current?.clearCurrentNode(); snapshotServiceRef.current = null; storeRef.current?.dispose(); storeRef.current = null; setStoreReady(false); setCanvasPhase('idle'); }; }, [user?.id, context.node?.id]); // Handle navigation changes โ€” save previous snapshot, load next one. // No automatic node shape placement: the canvas shows only persisted state. // Inject auth and trigger initial context when token is ready useEffect(() => { if (user?.id && accessToken) { setAuthInfo(accessToken, user.id); if (!context.node) { switchContext({ main: 'profile', base: 'profile' }, null, null); } } }, [user?.id, accessToken]); // 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 || !['admin', 'email_teacher', 'school_admin', 'teacher'].includes(user.user_type || '')) { 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 || !user) { return (
); } return (
{/* Loading overlay - show when loading or contexts not initialized */} {(loadingState.status === 'loading' || !storeReady) && (
)} {/* Error snackbar */} setLoadingState({ status: 'ready', error: '' })} > setLoadingState({ status: 'ready', error: '' })}> {loadingState.error}

Canvas failed to load.

}> {storeReady && storeRef.current && []} 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'); // Update snapshot service with editor reference if (snapshotServiceRef.current) { snapshotServiceRef.current.setEditor(editor); if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken); } logger.info('single-player-page', 'โœ… Tldraw mounted successfully', { editorId: editor.store.id, presentationMode, }); } catch (error) { logger.error('single-player-page', 'โŒ Error in onMount', error); } }} />} ); }