import React, { useCallback, useEffect, useState, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { ArrowLeft, Download, Loader2, ChevronUp, ChevronDown } from 'lucide-react'; import clsx from 'clsx'; import { Excalidraw, exportToSvg } from '@excalidraw/excalidraw'; import '@excalidraw/excalidraw/index.css'; import debounce from 'lodash/debounce'; import throttle from 'lodash/throttle'; import { Toaster, toast } from 'sonner'; import { io, Socket } from 'socket.io-client'; import { getUserIdentity, type UserIdentity } from '../utils/identity'; import { useAuth } from '../context/AuthContext'; import { reconcileElements } from '../utils/sync'; import { exportFromEditor } from '../utils/exportUtils'; import * as api from '../api'; import { useTheme } from '../context/ThemeContext'; import { UIOptions, getColorFromString, getFilesDelta, getInitialsFromName, haveSameElements, } from './editor/shared'; import type { ElementVersionInfo } from './editor/shared'; interface Peer extends UserIdentity { isActive: boolean; } export const Editor: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { theme } = useTheme(); const { user } = useAuth(); const [drawingName, setDrawingName] = useState('Drawing Editor'); const [isRenaming, setIsRenaming] = useState(false); const [newName, setNewName] = useState(''); const [initialData, setInitialData] = useState(null); const [isSceneLoading, setIsSceneLoading] = useState(true); const [loadError, setLoadError] = useState(null); const [isSavingOnLeave, setIsSavingOnLeave] = useState(false); const [isHeaderVisible, setIsHeaderVisible] = useState(true); const [autoHideEnabled, setAutoHideEnabled] = useState(true); useEffect(() => { document.title = `${drawingName} - ExcaliDash`; return () => { document.title = 'ExcaliDash'; }; }, [drawingName]); // Auto-hide header based on mouse movement useEffect(() => { if (!autoHideEnabled || isRenaming) { setIsHeaderVisible(true); return; } let hideTimeout: ReturnType | null = null; let isInTriggerZone = false; const handleMouseMove = throttle((e: MouseEvent) => { const wasInTriggerZone = isInTriggerZone; isInTriggerZone = e.clientY < 5; if (isInTriggerZone) { // Mouse is in trigger zone - show header setIsHeaderVisible(true); if (hideTimeout !== null) { clearTimeout(hideTimeout); hideTimeout = null; } } else if (wasInTriggerZone) { // Mouse just left trigger zone - start hide timer if (hideTimeout !== null) clearTimeout(hideTimeout); hideTimeout = setTimeout(() => { setIsHeaderVisible(false); }, 2000); } // If mouse is already out of trigger zone and moving, don't reset timer }, 100); // Show header initially setIsHeaderVisible(true); // Hide after initial delay if mouse doesn't move to top hideTimeout = setTimeout(() => { setIsHeaderVisible(false); }, 3000); window.addEventListener('mousemove', handleMouseMove, { passive: true }); return () => { window.removeEventListener('mousemove', handleMouseMove); if (hideTimeout !== null) clearTimeout(hideTimeout); }; }, [autoHideEnabled, isRenaming]); // Use authenticated user identity or fallback to generated identity const [me] = useState(() => { if (user) { return { id: user.id, name: user.name, initials: getInitialsFromName(user.name), color: getColorFromString(user.id), }; } return getUserIdentity(); }); const [peers, setPeers] = useState([]); const [isReady, setIsReady] = useState(false); const socketRef = useRef(null); const lastCursorEmit = useRef(0); const elementVersionMap = useRef>(new Map()); const isBootstrappingScene = useRef(true); const hasHydratedInitialScene = useRef(false); const isUnmounting = useRef(false); const isSyncing = useRef(false); const cursorBuffer = useRef>(new Map()); const animationFrameId = useRef(0); const latestElementsRef = useRef([]); const latestFilesRef = useRef(null); const lastSyncedFilesRef = useRef>({}); const latestAppStateRef = useRef(null); const debouncedSaveRef = useRef<((elements: readonly any[], appState: any) => void) | null>(null); const emitFilesDeltaIfNeeded = useCallback( (nextFiles: Record) => { if (!socketRef.current || !id) return false; const filesDelta = getFilesDelta(lastSyncedFilesRef.current, nextFiles || {}); if (Object.keys(filesDelta).length === 0) return false; latestFilesRef.current = nextFiles; lastSyncedFilesRef.current = nextFiles; if (import.meta.env.DEV) { const dbg = ((window as any).__EXCALIDASH_E2E_DEBUG__ ||= { fileEmits: 0, lastFilesDeltaIds: [] as string[], }); dbg.fileEmits += 1; dbg.lastFilesDeltaIds = Object.keys(filesDelta); } socketRef.current.emit("element-update", { drawingId: id, elements: [], files: filesDelta, userId: me.id, }); return true; }, [id, me.id] ); const recordElementVersion = useCallback((element: any) => { elementVersionMap.current.set(element.id, { version: element.version ?? 0, versionNonce: element.versionNonce ?? 0, }); }, []); const hasElementChanged = useCallback((element: any) => { const previous = elementVersionMap.current.get(element.id); if (!previous) return true; const nextVersion = element.version ?? 0; const nextNonce = element.versionNonce ?? 0; return previous.version !== nextVersion || previous.versionNonce !== nextNonce; }, []); useEffect(() => { isUnmounting.current = false; return () => { isUnmounting.current = true; }; }, []); useEffect(() => { if (!id || !isReady) return; const socketUrl = import.meta.env.VITE_API_URL === '/api' ? window.location.origin : (import.meta.env.VITE_API_URL || 'http://localhost:8000'); const authToken = localStorage.getItem('excalidash-access-token'); const socket = io(socketUrl, { path: '/socket.io', transports: ['websocket', 'polling'], auth: authToken ? { token: authToken } : {}, }); socketRef.current = socket; // DEV-only: expose socket status for E2E tests to wait for connection. if (import.meta.env.DEV) { (window as any).__EXCALIDASH_SOCKET_STATUS__ = { connected: socket.connected, }; socket.on("connect", () => { (window as any).__EXCALIDASH_SOCKET_STATUS__ = { connected: true }; }); socket.on("disconnect", () => { (window as any).__EXCALIDASH_SOCKET_STATUS__ = { connected: false }; }); } socket.emit('join-room', { drawingId: id, user: me }); // Start the render loop for cursors const renderLoop = () => { if (cursorBuffer.current.size > 0 && excalidrawAPI.current) { const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []); cursorBuffer.current.forEach((data, userId) => { collaborators.set(userId, data); }); cursorBuffer.current.clear(); excalidrawAPI.current.updateScene({ collaborators }); } animationFrameId.current = requestAnimationFrame(renderLoop); }; renderLoop(); socket.on('presence-update', (users: Peer[]) => { setPeers(users.filter(u => u.id !== me.id)); if (excalidrawAPI.current) { const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []); users.forEach(user => { if (!user.isActive && user.id !== me.id) { collaborators.delete(user.id); } }); excalidrawAPI.current.updateScene({ collaborators }); } }); socket.on('cursor-move', (data: any) => { cursorBuffer.current.set(data.userId, { pointer: data.pointer, button: data.button || 'up', selectedElementIds: data.selectedElementIds || {}, username: data.username, color: { background: data.color, stroke: data.color }, id: data.userId, }); }); socket.on('element-update', ({ elements, files }: { elements: any[]; files?: Record }) => { if (!excalidrawAPI.current) return; isSyncing.current = true; const currentAppState = excalidrawAPI.current.getAppState(); const mySelectedIds = currentAppState.selectedElementIds || {}; // Don't overwrite elements I'm actively editing/dragging in this tab, // BUT always apply remote deletions so all tabs converge. const validRemoteElements = elements.filter( (el: any) => el?.isDeleted || !mySelectedIds[el.id] ); const localElements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); const mergedElements = reconcileElements(localElements, validRemoteElements); validRemoteElements.forEach((el: any) => { recordElementVersion(el); }); const incomingFiles = files || {}; const shouldUpdateFiles = Object.keys(incomingFiles).length > 0; const nextFiles = shouldUpdateFiles ? { ...lastSyncedFilesRef.current, ...incomingFiles } : lastSyncedFilesRef.current; if (shouldUpdateFiles && typeof excalidrawAPI.current.addFiles === "function") { // Excalidraw manages binary files separately from scene elements; updateScene(files) // is not reliable for syncing pasted images across tabs. excalidrawAPI.current.addFiles(incomingFiles); } excalidrawAPI.current.updateScene({ elements: mergedElements }); latestElementsRef.current = mergedElements; if (shouldUpdateFiles) { latestFilesRef.current = nextFiles; lastSyncedFilesRef.current = nextFiles; } isSyncing.current = false; }); const handleActivity = (isActive: boolean) => { socket.emit('user-activity', { drawingId: id, isActive }); }; const onFocus = () => handleActivity(true); const onBlur = () => handleActivity(false); const onMouseEnter = () => handleActivity(true); const onMouseLeave = () => handleActivity(false); window.addEventListener('focus', onFocus); window.addEventListener('blur', onBlur); document.addEventListener('mouseenter', onMouseEnter); document.addEventListener('mouseleave', onMouseLeave); return () => { window.removeEventListener('focus', onFocus); window.removeEventListener('blur', onBlur); document.removeEventListener('mouseenter', onMouseEnter); document.removeEventListener('mouseleave', onMouseLeave); socket.off('presence-update'); socket.off('cursor-move'); socket.off('element-update'); socket.disconnect(); cancelAnimationFrame(animationFrameId.current); }; }, [id, me, isReady, recordElementVersion]); const onPointerUpdate = useCallback((payload: any) => { const now = Date.now(); if (now - lastCursorEmit.current > 50 && socketRef.current) { socketRef.current.emit('cursor-move', { pointer: payload.pointer, button: payload.button, username: me.name, userId: me.id, drawingId: id, color: me.color }); lastCursorEmit.current = now; } }, [id, me]); // Refs for API interaction const excalidrawAPI = useRef(null); const setExcalidrawAPI = useCallback((api: any) => { excalidrawAPI.current = api; // DEV-only: expose API for debugging/e2e reproduction of collaboration bugs. // This is intentionally not relied upon by app logic. if (import.meta.env.DEV) { (window as any).__EXCALIDASH_EXCALIDRAW_API__ = api; } // Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously) // are broadcast immediately even if Excalidraw doesn't trigger `onChange` for files. if (api && typeof api.addFiles === "function") { const originalAddFiles = api.addFiles.bind(api); api.addFiles = (files: Record) => { originalAddFiles(files); // Avoid rebroadcast loops when we are applying remote updates. if (isSyncing.current) return; const nextFiles = api.getFiles?.() || {}; const didEmit = emitFilesDeltaIfNeeded(nextFiles); // Persist after file data becomes available so new tabs (tab3) load correctly. if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) { debouncedSaveRef.current(latestElementsRef.current, latestAppStateRef.current); } }; } setIsReady(true); }, [emitFilesDeltaIfNeeded]); // Handle #addLibrary URL hash parameter for importing libraries from links useEffect(() => { if (!isReady || !excalidrawAPI.current) return; const hash = window.location.hash; if (!hash.includes('addLibrary=')) return; const params = new URLSearchParams(hash.slice(1)); // Remove the leading # const libraryUrl = params.get('addLibrary'); if (!libraryUrl) return; const importLibraryFromUrl = async () => { try { console.log('[Editor] Importing library from URL:', libraryUrl); toast.loading('Importing library...', { id: 'library-import' }); const response = await fetch(libraryUrl); if (!response.ok) { throw new Error(`Failed to fetch library: ${response.statusText}`); } const blob = await response.blob(); await excalidrawAPI.current.updateLibrary({ libraryItems: blob, merge: true, defaultStatus: "published", openLibraryMenu: true, }); const updatedItems = excalidrawAPI.current.getAppState().libraryItems || []; await api.updateLibrary([...updatedItems]); toast.success('Library imported successfully', { id: 'library-import' }); console.log('[Editor] Library import complete'); // Clear the hash to prevent re-importing on refresh window.history.replaceState(null, '', window.location.pathname + window.location.search); } catch (err) { console.error('[Editor] Failed to import library:', err); toast.error('Failed to import library', { id: 'library-import' }); } }; importLibraryFromUrl(); }, [isReady]); const buildEmptyScene = useCallback(() => ({ elements: [], appState: { viewBackgroundColor: '#ffffff', gridSize: null, collaborators: new Map(), }, files: {}, scrollToContent: true, }), []); const saveDataRef = useRef<((elements: readonly any[], appState: any) => Promise) | null>(null); const savePreviewRef = useRef<((elements: readonly any[], appState: any, files: any) => Promise) | null>(null); const saveLibraryRef = useRef<((items: any[]) => Promise) | null>(null); saveDataRef.current = async (elements: readonly any[], appState: any) => { if (!id) return; try { const persistableAppState = { ...appState, viewBackgroundColor: appState?.viewBackgroundColor || '#ffffff', gridSize: appState?.gridSize || null, }; const snapshot = latestElementsRef.current ?? elements ?? []; const persistableElements = Array.isArray(snapshot) ? snapshot : []; console.log("[Editor] Saving drawing", { drawingId: id, elementCount: persistableElements.length, hasRenderableElements: persistableElements.some((el: any) => !el?.isDeleted), appState: persistableAppState, }); await api.updateDrawing(id, { elements: persistableElements, appState: persistableAppState, files: latestFilesRef.current || {}, }); console.log("[Editor] Save complete", { drawingId: id }); } catch (err) { console.error('Failed to save drawing', err); toast.error("Failed to save changes"); } }; savePreviewRef.current = async (elements: readonly any[], appState: any, files: any) => { if (!id) return; try { const currentSnapshot = latestElementsRef.current ?? elements; const currentFiles = latestFilesRef.current ?? files; const svg = await exportToSvg({ elements: currentSnapshot, appState: { ...appState, exportBackground: true, viewBackgroundColor: appState.viewBackgroundColor || '#ffffff', }, files: currentFiles, }); const preview = svg.outerHTML; console.log("[Editor] Saving preview", { drawingId: id, elementCount: currentSnapshot.length, }); await api.updateDrawing(id, { preview }); console.log("[Editor] Preview save complete", { drawingId: id }); } catch (err) { console.error('Failed to save preview', err); } }; saveLibraryRef.current = async (items: any[]) => { try { console.log("[Editor] Saving library", { itemCount: items.length }); await api.updateLibrary(items); console.log("[Editor] Library save complete"); } catch (err) { console.error('Failed to save library', err); toast.error("Failed to save library"); } }; const debouncedSave = useCallback( debounce((elements, appState) => { if (saveDataRef.current) { saveDataRef.current(elements, appState); } }, 1000), [] // Empty dependency array = Stable across renders ); // Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves. debouncedSaveRef.current = debouncedSave; const debouncedSavePreview = useCallback( debounce((elements, appState, files) => { if (savePreviewRef.current) { savePreviewRef.current(elements, appState, files); } }, 10000), [] ); const debouncedSaveLibrary = useCallback( debounce((items: any[]) => { if (saveLibraryRef.current) { saveLibraryRef.current(items); } }, 1000), [] ); const broadcastChanges = useCallback( throttle((elements: readonly any[], currentFiles?: Record) => { if (!socketRef.current || !id) return; const changes: any[] = []; elements.forEach((el) => { if (hasElementChanged(el)) { changes.push(el); recordElementVersion(el); } }); const nextFiles = currentFiles || excalidrawAPI.current?.getFiles() || {}; const filesDelta = getFilesDelta(lastSyncedFilesRef.current, nextFiles); const shouldSyncFiles = Object.keys(filesDelta).length > 0; if (Object.keys(nextFiles || {}).length > 0) { latestFilesRef.current = nextFiles; } if (shouldSyncFiles) { // Keep our baseline in sync so we only send deltas next time. lastSyncedFilesRef.current = nextFiles; } if (changes.length > 0 || shouldSyncFiles) { socketRef.current.emit('element-update', { drawingId: id, elements: changes.length > 0 ? changes : [], files: shouldSyncFiles ? filesDelta : undefined, userId: me.id }); } }, 100, { leading: true, trailing: true }), [id, hasElementChanged, recordElementVersion] ); useEffect(() => { isBootstrappingScene.current = true; hasHydratedInitialScene.current = false; elementVersionMap.current.clear(); latestElementsRef.current = []; latestFilesRef.current = {}; lastSyncedFilesRef.current = {}; excalidrawAPI.current = null; setIsReady(false); setIsSceneLoading(true); setLoadError(null); setInitialData(null); const loadData = async () => { if (!id) { setInitialData(buildEmptyScene()); setIsSceneLoading(false); return; } try { const [data, libraryItems] = await Promise.all([ api.getDrawing(id), api.getLibrary().catch((err) => { console.warn('Failed to load library, using empty:', err); return []; }) ]); setDrawingName(data.name); const elements = data.elements || []; const files = data.files || {}; latestElementsRef.current = elements; latestFilesRef.current = files; lastSyncedFilesRef.current = files; elements.forEach((el: any) => { recordElementVersion(el); }); const persistedAppState = data.appState || {}; const hydratedAppState = { ...persistedAppState, viewBackgroundColor: persistedAppState.viewBackgroundColor ?? '#ffffff', gridSize: persistedAppState.gridSize ?? null, collaborators: new Map(), }; // Ensure we always have an appState available for file-only persistence triggers // (some Excalidraw file updates may not trigger onChange with appState). latestAppStateRef.current = hydratedAppState; setInitialData({ elements, appState: hydratedAppState, files, scrollToContent: true, libraryItems, }); } catch (err) { console.error('Failed to load drawing', err); let message = "Failed to load drawing"; if (api.isAxiosError(err)) { const responseMessage = typeof err.response?.data?.message === "string" ? err.response.data.message : null; if (responseMessage) { message = responseMessage; } else if (err.response?.status === 403) { message = "You do not have access to this drawing"; } else if (err.response?.status === 404) { message = "Drawing not found"; } } toast.error(message); latestElementsRef.current = []; latestFilesRef.current = {}; lastSyncedFilesRef.current = {}; setLoadError(message); setInitialData(null); } finally { setIsSceneLoading(false); } }; loadData(); }, [id, recordElementVersion, buildEmptyScene]); // Hijack Ctrl+S to save immediately useEffect(() => { const handleKeyDown = async (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) { const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); const appState = excalidrawAPI.current.getAppState(); const files = excalidrawAPI.current.getFiles() || {}; latestElementsRef.current = elements; latestFilesRef.current = files; await saveDataRef.current(elements, appState); savePreviewRef.current(elements, appState, files); toast.success("Saved changes to server"); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, []); const handleCanvasChange = useCallback((elements: readonly any[], appState: any, files?: Record) => { if (isUnmounting.current) { console.log("[Editor] Ignoring change during unmount", { drawingId: id }); return; } if (isSyncing.current) return; latestAppStateRef.current = appState; const currentFiles = files || excalidrawAPI.current?.getFiles() || {}; if (Object.keys(currentFiles).length > 0) { latestFilesRef.current = currentFiles; } // Get ALL elements including deleted (fixes the "deletion not syncing" bug) const allElements = excalidrawAPI.current ? excalidrawAPI.current.getSceneElementsIncludingDeleted() : elements; if (!hasHydratedInitialScene.current) { const matchesInitialSnapshot = haveSameElements(allElements, latestElementsRef.current); hasHydratedInitialScene.current = true; isBootstrappingScene.current = false; if (matchesInitialSnapshot) { console.log("[Editor] Skipping hydration change", { drawingId: id, elementCount: allElements.length, }); return; } console.log("[Editor] First live change after hydration", { drawingId: id, elementCount: allElements.length, }); } latestElementsRef.current = allElements; const hasRenderableElements = allElements.some((el: any) => !el?.isDeleted); if (isBootstrappingScene.current && !hasRenderableElements) { console.log("[Editor] Bootstrapping guard active", { drawingId: id, elementCount: allElements.length, }); return; } // Trigger Sync (Throttled) broadcastChanges(allElements, currentFiles); // Trigger Fast Save console.log("[Editor] Queueing save", { drawingId: id, elementCount: allElements.length, hasRenderableElements, }); debouncedSave(allElements, appState); // Trigger Slow Preview Gen const filesSnapshot = currentFiles; latestFilesRef.current = filesSnapshot; console.log("[Editor] Queueing preview save", { drawingId: id, fileCount: Object.keys(filesSnapshot).length, }); debouncedSavePreview(allElements, appState, filesSnapshot); }, [debouncedSave, debouncedSavePreview, broadcastChanges]); // Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously) // are still broadcast to collaborators AND persisted to the server. useEffect(() => { if (!id || !isReady) return; const interval = window.setInterval(() => { if (isUnmounting.current) return; if (isSyncing.current) return; if (!socketRef.current) return; if (!excalidrawAPI.current) return; const nextFiles = excalidrawAPI.current.getFiles?.() || {}; const didEmit = emitFilesDeltaIfNeeded(nextFiles); // Persist after file data becomes available (covers the "tab 3" case). if (didEmit && latestAppStateRef.current && debouncedSaveRef.current) { debouncedSaveRef.current(latestElementsRef.current, latestAppStateRef.current); } }, 1000); return () => window.clearInterval(interval); }, [id, isReady, emitFilesDeltaIfNeeded]); const handleRenameSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (newName.trim() && id) { setDrawingName(newName); setIsRenaming(false); try { await api.updateDrawing(id, { name: newName }); } catch (err) { console.error("Failed to rename", err); } } }; // Handle library changes and persist to server const handleLibraryChange = useCallback((items: readonly any[]) => { console.log("[Editor] Library changed", { itemCount: items.length }); debouncedSaveLibrary([...items]); }, [debouncedSaveLibrary]); // Disable native Excalidraw save dialogs const handleBackClick = async () => { if (isSavingOnLeave) return; // Prevent double clicks setIsSavingOnLeave(true); // Save drawing and generate preview before navigating try { if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) { const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); const appState = excalidrawAPI.current.getAppState(); const files = excalidrawAPI.current.getFiles() || {}; latestElementsRef.current = elements; latestFilesRef.current = files; await Promise.all([ saveDataRef.current(elements, appState), savePreviewRef.current(elements, appState, files) ]); console.log("[Editor] Saved on back navigation", { drawingId: id }); } } catch (err) { console.error('Failed to save on back navigation', err); } finally { setIsSavingOnLeave(false); } navigate('/'); }; return (
{isRenaming ? (
setNewName(e.target.value)} onBlur={() => setIsRenaming(false)} className="font-medium text-gray-900 dark:text-white bg-transparent px-2 py-1 border-2 border-indigo-500 rounded-md outline-none min-w-[200px]" style={{ width: `${Math.max(200, newName.length * 9 + 20)}px` }} />
) : (

{ setNewName(drawingName); setIsRenaming(true); }} > {drawingName}

)}
{/* Auto-hide Toggle */}
{/* Download Button */}
{me.initials}
{me.name} (You)
{peers.map(peer => (
{peer.initials}
{peer.name}
))}
{loadError ? (

Unable to open drawing

{loadError}

) : initialData ? ( ) : (
{isSceneLoading ? 'Loading drawing...' : 'Preparing canvas...'}
)}
); };