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 }); const [canvasPhase, setCanvasPhase] = useState< 'idle' | 'store-init' | 'snapshot-loading' | 'ready' | 'error' >('idle'); // Effect 1: Create the store ONCE per user session. Never recreate on node changes. // Disposing the store while Tldraw is subscribed causes currentPageId crashes; keeping // the store alive for the component lifetime avoids all disposal race conditions. useEffect(() => { if (!user) return; setCanvasPhase('store-init'); const store = createTLStore({ schema: customSchema, shapeUtils: allShapeUtils, bindingUtils: allBindingUtils, }); // Initialize default tldraw records (TLINSTANCE, page, cameras) before mount (store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable(); const snapSvc = new NavigationSnapshotService(store, undefined); if (accessToken) snapSvc.setAccessToken(accessToken); snapshotServiceRef.current = snapSvc; let debounce: ReturnType | null = null; const unsubscribe = store.listen(() => { if (!snapshotServiceRef.current?.getCurrentNodePath()) return; if (debounce) clearTimeout(debounce); debounce = setTimeout(async () => { await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {}); }, 2000); }); storeRef.current = store; setStoreReady(true); setCanvasPhase('ready'); return () => { unsubscribe?.(); snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {}); snapshotServiceRef.current?.clearCurrentNode(); snapshotServiceRef.current = null; setStoreReady(false); setCanvasPhase('idle'); store.dispose(); storeRef.current = null; }; }, [user?.id]); // Only recreate store if user changes, NOT on every node navigation // Effect 2: Load/reload snapshot when context.node changes. Store stays alive. useEffect(() => { if (!user || !storeRef.current) return; const store = storeRef.current; let cancelled = false; const loadNodeSnapshot = async () => { if (!context.node?.node_storage_path) { // No node โ€” clear to blank canvas using ensureStoreIsUsable to restore defaults (store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable(); snapshotServiceRef.current?.clearCurrentNode(); setCanvasPhase('ready'); return; } setCanvasPhase('snapshot-loading'); try { await NavigationSnapshotService.loadNodeSnapshotFromDatabase( context.node.node_storage_path, accessToken || '', store, setLoadingState, undefined, editorRef.current || undefined ); if (!cancelled) { // Repair store in case snapshot overwrote TLINSTANCE (store as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable(); snapshotServiceRef.current?.setCurrentNodePath(context.node.node_storage_path); setCanvasPhase('ready'); } } catch (e) { if (!cancelled) setCanvasPhase('ready'); // show blank canvas on error } }; loadNodeSnapshot(); return () => { cancelled = true; }; }, [context.node?.id, storeReady]); // re-run when node changes OR after store becomes ready // 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); } }} />} ); }