app/src/pages/tldraw/singlePlayerPage.tsx
CC Worker 5b6e461706
Some checks failed
app-ci-deploy / test-build-deploy (push) Has been cancelled
fix(canvas): separate store lifecycle from snapshot lifecycle — one store per user session prevents disposed-store crash
Root cause: disposing store while React's async state update (storeReady=false) hadn't
unmounted Tldraw yet caused tldraw's reactive signals to read currentPageId on a disposed store.

Fix: Effect 1 creates store once per user.id (never recreated on node navigation).
     Effect 2 loads/reloads snapshots when context.node changes, store stays alive.
     Store is only disposed when user changes or component unmounts — after setStoreReady(false)
     has had time to unmount Tldraw cleanly via React's synchronous cleanup.
2026-06-01 06:25:15 +00:00

416 lines
16 KiB
TypeScript

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<Editor | null>(null);
const snapshotServiceRef = useRef<NavigationSnapshotService | null>(null);
// State
const [loadingState, setLoadingState] = useState<LoadingState>({
status: 'ready',
error: ''
});
const storeRef = useRef<TLStore | null>(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<typeof setTimeout> | 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 (
<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`,
}}>
{/* Loading overlay - show when loading or contexts not initialized */}
{(loadingState.status === 'loading' || !storeReady) && (
<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>
<ErrorBoundary fallback={
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: '1rem', padding: '2rem' }}>
<p style={{ fontSize: '1.1rem' }}>Canvas failed to load.</p>
<button onClick={() => window.location.reload()} style={{ padding: '0.5rem 1.25rem', cursor: 'pointer' }}>Reload</button>
</div>
}>
{storeReady && storeRef.current && <Tldraw
user={tldrawUser}
store={storeRef.current}
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');
// 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);
}
}}
/>}
</ErrorBoundary>
</div>
);
}