app/src/pages/tldraw/singlePlayerPage.tsx
kcar e27d1f5c8d fix: prevent TLDraw topOffset crash — decouple store init from editor ready
Three changes to singlePlayerPage:

1. Store creation no longer waits for isEditorReady. The editor ref
   is already passed as optional to NavigationSnapshotService and
   loadNodeSnapshotFromDatabase, so we can create and populate the
   store without needing the editor to be mounted first.

2. <Tldraw> only renders when store is defined: {store && <Tldraw>}.
   Previously it rendered immediately with store={undefined}, which
   caused TLDraw to initialize with no backing store and crash
   reading topOffset on an unresolved internal object.

3. Loading guard also checks !user so the component never renders
   with a null profile before the redirect effect fires.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 18:17:52 +00:00

633 lines
25 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,
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 { profile: 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 — runs as soon as user is ready, no editor needed for store creation
useEffect(() => {
if (!user) {
logger.debug('single-player-page', '⏳ Waiting for user data');
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, editorRef.current || undefined);
snapshotServiceRef.current = snapshotService;
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
// 3. Load initial snapshot if we have a node
if (context.node) {
const nodeStoragePath = getNodeStoragePath(context.node);
if (nodeStoragePath) {
logger.debug('single-player-page', '📥 Loading snapshot from database', {
dbName: user.user_db_name,
node: context.node,
node_storage_path: nodeStoragePath,
user_type: user.user_type,
username: user.username
});
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
nodeStoragePath,
user.user_db_name,
newStore,
setLoadingState,
undefined, // sharedStore
editorRef.current || undefined // editor
);
logger.debug('single-player-page', '✅ Snapshot loaded from database');
} else {
logger.debug('single-player-page', '⚠️ No node_storage_path found in node, skipping snapshot load', {
node: context.node
});
}
} else {
logger.debug('single-player-page', '⚠️ No node in context, skipping snapshot load');
}
// 4. Set up auto-save with debouncing (only after initial load is complete)
let autoSaveTimeout: ReturnType<typeof setTimeout> | null = null;
let isAutoSaving = false;
newStore.listen(() => {
if (snapshotServiceRef.current && context.node && snapshotServiceRef.current.getCurrentNodePath()) {
// Skip if already saving
if (isAutoSaving) {
logger.debug('single-player-page', '⚠️ Skipping auto-save - already saving');
return;
}
// Clear existing timeout
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout);
}
// Debounce auto-save to prevent excessive saves
autoSaveTimeout = setTimeout(async () => {
if (isAutoSaving) return; // Double-check
isAutoSaving = true;
try {
logger.debug('single-player-page', '💾 Auto-saving changes (debounced)');
await snapshotServiceRef.current?.forceSaveCurrentNode();
} catch (error) {
logger.error('single-player-page', '❌ Auto-save failed', error);
} finally {
isAutoSaving = false;
}
}, 2000); // Increased to 2 seconds debounce
} else if (snapshotServiceRef.current && context.node && !snapshotServiceRef.current.getCurrentNodePath()) {
logger.debug('single-player-page', '⚠️ Skipping auto-save - no current node path set yet');
}
});
// 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();
}, [user, context.node]);
// Handle initial node placement
useEffect(() => {
const placeInitialNode = async () => {
if (!context.node || !editorRef.current || !store || !isInitialLoad) {
logger.debug('single-player-page', '⚠️ Skipping placeInitialNode - missing dependencies', {
hasNode: !!context.node,
hasEditor: !!editorRef.current,
hasStore: !!store,
isInitialLoad
});
return;
}
// Debug: Log the actual node structure
logger.debug('single-player-page', '🔍 Node structure for placeInitialNode', {
node: context.node,
nodeKeys: Object.keys(context.node),
hasId: !!context.node.id,
hasStoragePath: !!context.node.node_storage_path,
hasData: !!context.node.data,
dataKeys: context.node.data ? Object.keys(context.node.data) : null
});
// Validate that the node has required properties
const nodeStoragePath = getNodeStoragePath(context.node);
if (!context.node.id || !nodeStoragePath) {
logger.error('single-player-page', '❌ Node missing required properties', {
nodeId: context.node.id,
hasStoragePath: !!nodeStoragePath,
node: context.node
});
setLoadingState({
status: 'error',
error: 'Node is missing required information'
});
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,
node_storage_path: currentNode.node_storage_path,
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, context.history, store, isInitialLoad]);
// 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`,
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>
{store && <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');
// Update snapshot service with editor reference
if (snapshotServiceRef.current) {
snapshotServiceRef.current.setEditor(editor);
}
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>
);
}
// Helper function to safely extract node_storage_path from different node structures
const getNodeStoragePath = (node: NavigationNode): string | null => {
// Try direct access first
if (node.node_storage_path) {
return node.node_storage_path;
}
// Try nested under data
if (node.data?.node_storage_path) {
return node.data.node_storage_path;
}
// Try other possible locations
if (node.data?.storage_path && typeof node.data.storage_path === 'string') {
return node.data.storage_path;
}
return null;
};
const loadNodeData = async (node: NavigationNode): Promise<NodeData> => {
// Validate the node parameter
if (!node) {
throw new Error('Node parameter is required');
}
if (!node.id) {
throw new Error('Node must have an ID');
}
const nodeStoragePath = getNodeStoragePath(node);
if (!nodeStoragePath) {
throw new Error(`Node ${node.id} is missing node_storage_path`);
}
logger.debug('single-player-page', '🔄 Loading node data', {
nodeId: node.id,
nodeType: node.type,
nodeLabel: node.label,
nodeStoragePath: nodeStoragePath
});
try {
// 1. Always fetch fresh data
// Create a temporary node object with the correct structure for the service
const normalizedNode = {
...node,
node_storage_path: nodeStoragePath
};
const dbName = UserNeoDBService.getNodeDatabaseName(normalizedNode);
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: String(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,
uuid_string: node.id,
node_storage_path: nodeStoragePath
};
} catch (error) {
logger.error('single-player-page', '❌ Error in loadNodeData', {
nodeId: node.id,
nodeType: node.type,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
};