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>
633 lines
25 KiB
TypeScript
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;
|
|
}
|
|
};
|