280 lines
9.2 KiB
TypeScript
280 lines
9.2 KiB
TypeScript
import { useEffect, useRef, useMemo, useState } from 'react';
|
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
import {
|
|
Tldraw,
|
|
Editor,
|
|
useTldrawUser,
|
|
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
|
DEFAULT_SUPPORT_VIDEO_TYPES,
|
|
} from '@tldraw/tldraw';
|
|
import { useSync } from '@tldraw/sync';
|
|
// App context
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { useTLDraw } from '../../contexts/TLDrawContext';
|
|
import { useNeoInstitute } from '../../contexts/NeoInstituteContext';
|
|
// Tldraw services
|
|
import { multiplayerOptions } from '../../services/tldraw/optionsService';
|
|
import { PresentationService } from '../../services/tldraw/presentationService';
|
|
import { createSyncConnectionOptions, handleExternalAsset } from '../../services/tldraw/syncService';
|
|
// Tldraw utils
|
|
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
|
import { customAssets } from '../../utils/tldraw/assets';
|
|
import { multiplayerTools } from '../../utils/tldraw/tools';
|
|
import { allShapeUtils } from '../../utils/tldraw/shapes';
|
|
import { customSchema } from '../../utils/tldraw/schemas';
|
|
import { allBindingUtils } from '../../utils/tldraw/bindings';
|
|
import { multiplayerEmbeds } from '../../utils/tldraw/embeds';
|
|
// Layout
|
|
import { HEADER_HEIGHT } from '../../pages/Layout';
|
|
import { TLSYNC_URL } from '../../config/apiConfig';
|
|
// Styles
|
|
import '../../utils/tldraw/tldraw.css';
|
|
// App debug
|
|
import { logger } from '../../debugConfig';
|
|
|
|
const SYNC_WORKER_URL = TLSYNC_URL;
|
|
|
|
const apiBase = (import.meta.env.VITE_API_BASE as string) || '';
|
|
|
|
/**
|
|
* Fetches a short-lived TLSync token from the API using the current Supabase session.
|
|
* Returns { token, error } — exactly one will be non-null.
|
|
*/
|
|
function useTlsyncToken(accessToken: string | null) {
|
|
const [token, setToken] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!accessToken) return;
|
|
let cancelled = false;
|
|
|
|
logger.debug('multiplayer-page', '🔑 Fetching TLSync token from API');
|
|
fetch(`${apiBase}/api/tlsync/token`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
})
|
|
.then((r) => {
|
|
if (!r.ok) throw new Error(`Token request failed: ${r.status}`);
|
|
return r.json();
|
|
})
|
|
.then((data) => {
|
|
if (!cancelled) {
|
|
logger.debug('multiplayer-page', '✅ TLSync token received', { expiresIn: data.expires_in });
|
|
setToken(data.token);
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
if (!cancelled) {
|
|
logger.error('multiplayer-page', '❌ Failed to fetch TLSync token', { err: err?.message });
|
|
setError(err?.message || 'Unknown error');
|
|
}
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [accessToken]);
|
|
|
|
return { token, error };
|
|
}
|
|
|
|
/**
|
|
* Loading / error overlay shown while the TLSync token is being fetched.
|
|
*/
|
|
function TlsyncStatusOverlay({ message }: { message: string }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
top: `${HEADER_HEIGHT}px`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
padding: '20px',
|
|
backgroundColor: 'white',
|
|
borderRadius: '8px',
|
|
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
|
}}
|
|
>
|
|
{message}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Inner component that owns the useSync hook.
|
|
* Rendered only once a valid TLSync token has been fetched from the API.
|
|
*/
|
|
function TldrawCanvas({
|
|
tlsyncToken,
|
|
roomId,
|
|
}: {
|
|
tlsyncToken: string;
|
|
roomId: string;
|
|
}) {
|
|
const { user } = useAuth();
|
|
const {
|
|
tldrawPreferences,
|
|
setTldrawPreferences,
|
|
initializePreferences,
|
|
presentationMode,
|
|
} = useTLDraw();
|
|
const { isLoading: isInstituteLoading, isInitialized: isInstituteInitialized } = useNeoInstitute();
|
|
const editorRef = useRef<Editor | null>(null);
|
|
|
|
const userInfo = useMemo(
|
|
() => ({
|
|
id: user?.id ?? '',
|
|
name: user?.display_name ?? user?.email?.split('@')[0] ?? 'Anonymous User',
|
|
color: tldrawPreferences?.color ?? `hsl(${Math.random() * 360}, 70%, 50%)`,
|
|
}),
|
|
[user?.id, user?.display_name, user?.email, tldrawPreferences?.color],
|
|
);
|
|
|
|
const editorUser = useTldrawUser({
|
|
userPreferences: {
|
|
id: userInfo.id,
|
|
name: userInfo.name,
|
|
color: userInfo.color,
|
|
locale: tldrawPreferences?.locale,
|
|
colorScheme: tldrawPreferences?.colorScheme,
|
|
animationSpeed: tldrawPreferences?.animationSpeed,
|
|
isSnapMode: tldrawPreferences?.isSnapMode,
|
|
},
|
|
setUserPreferences: setTldrawPreferences,
|
|
});
|
|
|
|
const connectionOptions = useMemo(
|
|
() =>
|
|
createSyncConnectionOptions({
|
|
userId: userInfo.id,
|
|
displayName: userInfo.name,
|
|
color: userInfo.color,
|
|
roomId,
|
|
baseUrl: SYNC_WORKER_URL,
|
|
token: tlsyncToken,
|
|
}),
|
|
[userInfo, roomId, tlsyncToken],
|
|
);
|
|
|
|
const store = useSync({
|
|
...connectionOptions,
|
|
schema: customSchema,
|
|
shapeUtils: allShapeUtils,
|
|
bindingUtils: allBindingUtils,
|
|
userInfo: {
|
|
id: userInfo.id,
|
|
name: userInfo.name,
|
|
color: userInfo.color,
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
logger.info('multiplayer-page', `🔄 Connection status changed: ${store.status}`, {
|
|
status: store.status,
|
|
roomId: connectionOptions.roomId,
|
|
});
|
|
}, [store.status, connectionOptions.roomId]);
|
|
|
|
useEffect(() => {
|
|
if (user?.id && !tldrawPreferences) {
|
|
logger.info('multiplayer-page', '🔄 Initializing preferences');
|
|
initializePreferences(user.id);
|
|
}
|
|
}, [user?.id, tldrawPreferences, initializePreferences]);
|
|
|
|
useEffect(() => {
|
|
if (presentationMode && editorRef.current) {
|
|
const editor = editorRef.current;
|
|
const presentationService = new PresentationService(editor);
|
|
const cleanup = presentationService.startPresentationMode();
|
|
return () => {
|
|
presentationService.stopPresentationMode();
|
|
cleanup();
|
|
};
|
|
}
|
|
}, [presentationMode]);
|
|
|
|
const uiOverrides = useMemo(() => getUiOverrides(presentationMode), [presentationMode]);
|
|
const uiComponents = useMemo(() => getUiComponents(presentationMode), [presentationMode]);
|
|
|
|
if (store.status !== 'synced-remote' || isInstituteLoading || !isInstituteInitialized) {
|
|
return <TlsyncStatusOverlay message={`Connecting to room: ${roomId}...`} />;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'fixed',
|
|
inset: 0,
|
|
top: `${HEADER_HEIGHT}px`,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<Tldraw
|
|
user={editorUser}
|
|
store={store.store}
|
|
onMount={(editor) => {
|
|
editorRef.current = editor;
|
|
editor.registerExternalAssetHandler('url', async ({ url }: { url: string }) => {
|
|
return handleExternalAsset(SYNC_WORKER_URL, url);
|
|
});
|
|
}}
|
|
options={multiplayerOptions}
|
|
embeds={multiplayerEmbeds}
|
|
tools={multiplayerTools}
|
|
shapeUtils={allShapeUtils}
|
|
bindingUtils={allBindingUtils}
|
|
overrides={uiOverrides}
|
|
components={uiComponents}
|
|
assetUrls={customAssets}
|
|
autoFocus={true}
|
|
hideUi={false}
|
|
acceptedImageMimeTypes={DEFAULT_SUPPORTED_IMAGE_TYPES}
|
|
acceptedVideoMimeTypes={DEFAULT_SUPPORT_VIDEO_TYPES}
|
|
maxImageDimension={Infinity}
|
|
maxAssetSize={100 * 1024 * 1024}
|
|
renderDebugMenuItems={() => []}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function TldrawMultiUser() {
|
|
const { user, accessToken } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [searchParams] = useSearchParams();
|
|
const roomId = searchParams.get('room') || 'multiplayer';
|
|
|
|
const { token: tlsyncToken, error: tlsyncError } = useTlsyncToken(accessToken);
|
|
|
|
// Redirect unauthenticated users
|
|
useEffect(() => {
|
|
if (!user) {
|
|
navigate('/');
|
|
}
|
|
}, [user, navigate]);
|
|
|
|
if (!user) {
|
|
return null;
|
|
}
|
|
|
|
if (tlsyncError) {
|
|
return <TlsyncStatusOverlay message={`Failed to connect to collaboration server: ${tlsyncError}`} />;
|
|
}
|
|
|
|
if (!tlsyncToken) {
|
|
return <TlsyncStatusOverlay message="Connecting to collaboration server..." />;
|
|
}
|
|
|
|
return <TldrawCanvas tlsyncToken={tlsyncToken} roomId={roomId} />;
|
|
}
|