import React, { useCallback, useEffect, useState, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { ArrowLeft } from 'lucide-react'; 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 } from '../utils/identity'; import { reconcileElements } from '../utils/sync'; import type { UserIdentity } from '../utils/identity'; import * as api from '../api'; import { useTheme } from '../context/ThemeContext'; interface Peer extends UserIdentity { isActive: boolean; } interface ElementVersionInfo { version: number; versionNonce: number; } const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => { if (!a || !b) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { const left = a[i]; const right = b[i]; if (!left || !right) return false; if (left.id !== right.id) return false; if ((left.version ?? 0) !== (right.version ?? 0)) return false; if ((left.versionNonce ?? 0) !== (right.versionNonce ?? 0)) return false; } return true; }; // Move UIOptions outside to prevent re-creation on every render const UIOptions = { canvasActions: { saveToActiveFile: false, loadScene: false, export: { saveFileToDisk: false }, toggleTheme: true, }, }; export const Editor: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { theme } = useTheme(); 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 [peers, setPeers] = useState([]); const [me] = useState(getUserIdentity()); 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 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; // For production/Docker, connect to same origin. For dev, use localhost:8000 const socketUrl = import.meta.env.VITE_API_URL === '/api' ? window.location.origin : (import.meta.env.VITE_API_URL || 'http://localhost:8000'); const socket = io(socketUrl, { path: '/socket.io', transports: ['websocket', 'polling'], }); socketRef.current = socket; 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)); // Update collaborators map to remove inactive users 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) => { // Just buffer the data cursorBuffer.current.set(data.userId, { pointer: data.pointer, button: data.button || 'up', selectedElementIds: data.selectedElementIds || {}, username: data.username, avatarUrl: data.avatarUrl, color: { background: data.color, stroke: data.color }, id: data.userId, }); }); socket.on('element-update', ({ elements }: { elements: any[] }) => { if (!excalidrawAPI.current) return; isSyncing.current = true; // 3. THE SELECTION GUARD (Fixes Dragging/Snap-back) // Get IDs of elements YOU are currently holding const currentAppState = excalidrawAPI.current.getAppState(); const mySelectedIds = currentAppState.selectedElementIds || {}; // Filter out updates for elements you are currently dragging // This prevents the server from pulling the object out of your hand const validRemoteElements = elements.filter((el: any) => !mySelectedIds[el.id]); const localElements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); const mergedElements = reconcileElements(localElements, validRemoteElements); // Update version map with remote versions to avoid echoing validRemoteElements.forEach((el: any) => { recordElementVersion(el); }); excalidrawAPI.current.updateScene({ elements: mergedElements }); latestElementsRef.current = mergedElements; isSyncing.current = false; }); // Activity Tracking 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; setIsReady(true); }, []); const buildEmptyScene = useCallback(() => ({ elements: [], appState: { viewBackgroundColor: '#ffffff', gridSize: null, collaborators: new Map(), }, files: {}, scrollToContent: true, }), []); // ------------------------------------------------------------------ // 1. STABLE SAVE LOGIC (The Fix) // We use a Ref to hold the save function so the debounce wrapper // doesn't need to be recreated on every render. // ------------------------------------------------------------------ const saveDataRef = useRef<((elements: readonly any[], appState: any) => Promise) | null>(null); const savePreviewRef = useRef<((elements: readonly any[], appState: any, files: any) => Promise) | null>(null); // Update the ref on every render to ensure it has access to the latest props/state saveDataRef.current = async (elements: readonly any[], appState: any) => { if (!id) return; try { const persistableAppState = { viewBackgroundColor: appState.viewBackgroundColor, gridSize: appState.gridSize, }; const snapshot = latestElementsRef.current ?? elements; const persistableElements = Array.from(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; // Generate preview 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); } }; // Create the debounced function ONLY ONCE. // It simply calls whatever is currently in saveDataRef.current const debouncedSave = useCallback( debounce((elements, appState) => { if (saveDataRef.current) { saveDataRef.current(elements, appState); } }, 1000), [] // Empty dependency array = Stable across renders ); const debouncedSavePreview = useCallback( debounce((elements, appState, files) => { if (savePreviewRef.current) { savePreviewRef.current(elements, appState, files); } }, 10000), [] ); const broadcastChanges = useCallback( throttle((elements: readonly any[]) => { if (!socketRef.current || !id) return; const changes: any[] = []; elements.forEach((el) => { if (hasElementChanged(el)) { changes.push(el); recordElementVersion(el); } }); if (changes.length > 0) { socketRef.current.emit('element-update', { drawingId: id, elements: changes, userId: me.id }); } }, 100, { leading: true, trailing: true }), [id, hasElementChanged, recordElementVersion] ); // ------------------------------------------------------------------ // 2. DATA LOADING // ------------------------------------------------------------------ useEffect(() => { isBootstrappingScene.current = true; hasHydratedInitialScene.current = false; elementVersionMap.current.clear(); latestElementsRef.current = []; latestFilesRef.current = {}; excalidrawAPI.current = null; setIsReady(false); setIsSceneLoading(true); setInitialData(null); const loadData = async () => { if (!id) { setInitialData(buildEmptyScene()); setIsSceneLoading(false); return; } try { const data = await api.getDrawing(id); setDrawingName(data.name); // Use elements directly without converting - they're already normalized during import const elements = data.elements || []; const files = data.files || {}; latestElementsRef.current = elements; latestFilesRef.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(), }; setInitialData({ elements, appState: hydratedAppState, files, scrollToContent: true, }); } catch (err) { console.error('Failed to load drawing', err); toast.error("Failed to load drawing"); latestElementsRef.current = []; latestFilesRef.current = {}; setInitialData(buildEmptyScene()); } finally { setIsSceneLoading(false); } }; loadData(); }, [id, recordElementVersion, buildEmptyScene]); // ------------------------------------------------------------------ // 3. HANDLERS // ------------------------------------------------------------------ // 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; // Call save immediately, bypassing debounce await saveDataRef.current(elements, appState); // Also update preview 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) => { if (isUnmounting.current) { console.log("[Editor] Ignoring change during unmount", { drawingId: id }); return; } // 4. STOP THE ECHO // If this change was caused by a socket update, do NOT broadcast it back if (isSyncing.current) return; // 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); // Trigger Fast Save console.log("[Editor] Queueing save", { drawingId: id, elementCount: allElements.length, hasRenderableElements, }); debouncedSave(allElements, appState); // Trigger Slow Preview Gen const files = excalidrawAPI.current?.getFiles() || {}; latestFilesRef.current = files; console.log("[Editor] Queueing preview save", { drawingId: id, fileCount: Object.keys(files).length, }); debouncedSavePreview(allElements, appState, files); }, [debouncedSave, debouncedSavePreview, broadcastChanges]); 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); } } }; // Disable native Excalidraw save dialogs // UIOptions is now defined outside the component const handleBackClick = async () => { // Save drawing and generate preview before navigating 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; try { // Save both drawing data and preview 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); } } 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}

)}
{me.initials}
{me.name} (You)
{peers.map(peer => (
{peer.initials}
{peer.name}
))}
{initialData ? ( ) : (
{isSceneLoading ? 'Loading drawing...' : 'Preparing canvas...'}
)}
); };