405 lines
16 KiB
TypeScript
405 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
|
|
} 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
|
|
});
|
|
|
|
// 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<string>('');
|
|
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
let cancelled = false;
|
|
|
|
const run = async () => {
|
|
try {
|
|
setCanvasPhase('store-init');
|
|
const newStore = localStoreService.getStore({
|
|
schema: customSchema,
|
|
shapeUtils: allShapeUtils,
|
|
bindingUtils: allBindingUtils
|
|
});
|
|
// Pre-initialize tldraw's required records (TLINSTANCE, page, cameras)
|
|
// so computed signals that read currentPageId don't crash before Editor construction completes
|
|
(newStore as unknown as { ensureStoreIsUsable(): void }).ensureStoreIsUsable();
|
|
|
|
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;
|
|
}
|
|
|
|
let debounce: ReturnType<typeof setTimeout> | 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;
|
|
localStoreService.clearStore(); // reset singleton so next getStore() creates a fresh store
|
|
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 (
|
|
<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>
|
|
);
|
|
}
|
|
|