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 debounce from 'lodash/debounce'; import throttle from 'lodash/throttle'; import { Toaster, toast } from 'sonner'; import { io, Socket } from 'socket.io-client'; import 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, getFilesDelta, hasRenderableElements, haveSameElements, isSuspiciousEmptySnapshot, isStaleEmptySnapshot, isStaleNonRenderableSnapshot, } from './editor/shared'; import type { ElementVersionInfo } from './editor/shared'; import { useEditorChrome } from './editor/useEditorChrome'; import { useEditorIdentity } from './editor/useEditorIdentity'; interface Peer extends UserIdentity { isActive: boolean; } class DrawingSaveConflictError extends Error { constructor(message = "Drawing version conflict") { super(message); this.name = "DrawingSaveConflictError"; } } 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 [autoHideEnabled, setAutoHideEnabled] = useState(true); const { isHeaderVisible, setIsHeaderVisible } = useEditorChrome({ drawingName, autoHideEnabled, isRenaming, }); const me: UserIdentity = useEditorIdentity(user); 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 initialSceneElementsRef = useRef([]); const latestFilesRef = useRef(null); const lastSyncedFilesRef = useRef>({}); const latestAppStateRef = useRef(null); const debouncedSaveRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files?: Record) => void) | null>(null); const currentDrawingVersionRef = useRef(null); const lastPersistedElementsRef = useRef([]); const saveQueueRef = useRef>(Promise.resolve()); const patchedAddFilesApisRef = useRef>(new WeakSet()); const suspiciousBlankLoadRef = useRef(false); const hasSceneChangesSinceLoadRef = useRef(false); const getRenderableBaselineSnapshot = useCallback((): readonly any[] => { if (hasRenderableElements(lastPersistedElementsRef.current)) { return lastPersistedElementsRef.current; } if (hasRenderableElements(initialSceneElementsRef.current)) { return initialSceneElementsRef.current; } return latestElementsRef.current; }, []); const hasIntentionalDeletionDelta = useCallback( (baseline: readonly any[] = [], candidate: readonly any[] = []): boolean => { if (!Array.isArray(candidate) || candidate.length === 0) return false; if (!hasRenderableElements(baseline)) return false; if (hasRenderableElements(candidate)) return false; const baselineById = new Map( baseline.map((element: any) => [element?.id, element]) ); const getVersion = (element: any): number => typeof element?.version === "number" ? element.version : 0; const getUpdated = (element: any): number => { const value = element?.updated; return typeof value === "number" ? value : Number(value) || 0; }; return candidate.some((element: any) => { if (!element || element.isDeleted !== true || typeof element.id !== "string") { return false; } const previous = baselineById.get(element.id); if (!previous) return false; if (previous.isDeleted === true) return false; const nextVersion = getVersion(element); const prevVersion = getVersion(previous); if (nextVersion > prevVersion) return true; const nextUpdated = getUpdated(element); const prevUpdated = getUpdated(previous); if (nextVersion === prevVersion && nextUpdated > prevUpdated) return true; // Fallback for callers that may not bump version/updated consistently. return nextVersion === prevVersion && nextUpdated === prevUpdated; }); }, [] ); const resolveSafeSnapshot = useCallback( (candidateSnapshot: readonly any[] = []) => { const baseline = getRenderableBaselineSnapshot(); const staleEmptySnapshot = isStaleEmptySnapshot(baseline, candidateSnapshot); const staleNonRenderableSnapshot = isStaleNonRenderableSnapshot( baseline, candidateSnapshot ); const intentionalDeletionDelta = staleNonRenderableSnapshot ? hasIntentionalDeletionDelta(baseline, candidateSnapshot) : false; if (staleEmptySnapshot || (staleNonRenderableSnapshot && !intentionalDeletionDelta)) { return { snapshot: baseline, prevented: true, staleEmptySnapshot, staleNonRenderableSnapshot, } as const; } return { snapshot: candidateSnapshot, prevented: false, staleEmptySnapshot: false, staleNonRenderableSnapshot: false, } as const; }, [getRenderableBaselineSnapshot] ); const normalizeImageElementStatus = useCallback( (elements: readonly any[] = [], files?: Record | null): readonly any[] => { if (!Array.isArray(elements) || elements.length === 0) return elements; const fileMap = files || {}; let changed = false; const normalized = elements.map((element: any) => { if (!element || element.type !== "image" || typeof element.fileId !== "string") { return element; } const file = fileMap[element.fileId]; const hasImageData = typeof file?.dataURL === "string" && file.dataURL.startsWith("data:image/") && file.dataURL.length > 0; if (!hasImageData || element.status === "saved") { return element; } changed = true; return { ...element, status: "saved", }; }); return changed ? normalized : elements; }, [] ); 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 apiUrl = import.meta.env.VITE_API_URL || '/api'; const usingRelativeApiBasePath = apiUrl.startsWith('/'); const socketUrl = usingRelativeApiBasePath ? window.location.origin : apiUrl; const socketPath = (() => { if (usingRelativeApiBasePath) { const normalized = apiUrl.endsWith('/') && apiUrl.length > 1 ? apiUrl.slice(0, -1) : apiUrl; return normalized === '/' ? '/socket.io' : `${normalized}/socket.io`; } try { const parsed = new URL(apiUrl); const normalizedPath = parsed.pathname.length > 1 && parsed.pathname.endsWith('/') ? parsed.pathname.slice(0, -1) : parsed.pathname; return normalizedPath === '/' ? '/socket.io' : `${normalizedPath}/socket.io`; } catch { return '/socket.io'; } })(); const socket = io(socketUrl, { path: socketPath, transports: ['websocket', 'polling'], withCredentials: true, }); 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(Object.values(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" && !patchedAddFilesApisRef.current.has(api as object)) { patchedAddFilesApisRef.current.add(api as object); const originalAddFiles = api.addFiles.bind(api); api.addFiles = (filesInput: Record | any[]) => { const normalizedFiles = Array.isArray(filesInput) ? filesInput : Object.values(filesInput || {}); originalAddFiles(normalizedFiles); // 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 && id && latestAppStateRef.current && debouncedSaveRef.current) { hasSceneChangesSinceLoadRef.current = true; debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, latestFilesRef.current || {}); } }; } setIsReady(true); }, [emitFilesDeltaIfNeeded, id]); // 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<((drawingId: string, elements: readonly any[], appState: any, files?: Record) => Promise) | null>(null); const savePreviewRef = useRef<((drawingId: string, elements: readonly any[], appState: any, files: any) => Promise) | null>(null); const saveLibraryRef = useRef<((items: any[]) => Promise) | null>(null); saveDataRef.current = async (drawingId: string, elements: readonly any[], appState: any, files?: Record) => { if (!drawingId) return; try { const persistableAppState = { ...appState, viewBackgroundColor: appState?.viewBackgroundColor || '#ffffff', gridSize: appState?.gridSize || null, }; const candidateElements = Array.isArray(elements) ? elements : []; const { snapshot: safeElements, prevented, staleEmptySnapshot, staleNonRenderableSnapshot, } = resolveSafeSnapshot(candidateElements); const persistableElements = Array.from(safeElements); if (suspiciousBlankLoadRef.current && !hasRenderableElements(persistableElements)) { console.warn("[Editor] Blocking non-renderable save due to suspicious blank load", { drawingId, elementCount: persistableElements.length, }); return; } if (staleEmptySnapshot || staleNonRenderableSnapshot) { console.warn("[Editor] Skipping stale snapshot save", { drawingId, candidateElementCount: candidateElements.length, fallbackElementCount: persistableElements.length, prevented, staleEmptySnapshot, staleNonRenderableSnapshot, }); return; } const persistableFiles = files ?? latestFilesRef.current ?? {}; const normalizedElements = normalizeImageElementStatus( persistableElements, persistableFiles ); const normalizedElementsForSave = Array.from(normalizedElements); console.log("[Editor] Saving drawing", { drawingId, elementCount: normalizedElementsForSave.length, hasRenderableElements: hasRenderableElements(normalizedElementsForSave), appState: persistableAppState, }); const persistScene = async (attempt: number): Promise => { try { const updated = await api.updateDrawing(drawingId, { elements: normalizedElementsForSave, appState: persistableAppState, files: persistableFiles, version: currentDrawingVersionRef.current ?? undefined, }); if (typeof updated.version === "number") { currentDrawingVersionRef.current = updated.version; } lastPersistedElementsRef.current = normalizedElementsForSave; console.log("[Editor] Save complete", { drawingId }); } catch (err) { if (api.isAxiosError(err) && err.response?.status === 409) { const reportedVersion = Number(err.response?.data?.currentVersion); const hasReportedVersion = Number.isInteger(reportedVersion) && reportedVersion > 0; if (hasReportedVersion) { currentDrawingVersionRef.current = reportedVersion; } if (attempt === 0 && hasReportedVersion) { console.warn("[Editor] Version conflict while saving drawing, retrying once", { drawingId, currentVersion: reportedVersion, }); await persistScene(1); return; } throw new DrawingSaveConflictError(); } throw err; } }; await persistScene(0); } catch (err) { if (err instanceof DrawingSaveConflictError) { console.warn("[Editor] Version conflict while saving drawing", { drawingId }); toast.error("Drawing changed in another tab. Refresh to load latest."); throw err; } console.error('Failed to save drawing', err); toast.error("Failed to save changes"); throw err; } }; const enqueueSceneSave = useCallback( ( drawingId: string, elements: readonly any[], appState: any, files?: Record, options?: { suppressErrors?: boolean } ) => { const suppressErrors = options?.suppressErrors ?? true; saveQueueRef.current = saveQueueRef.current .catch(() => undefined) .then(async () => { if (!saveDataRef.current) return; if (suppressErrors) { try { await saveDataRef.current(drawingId, elements, appState, files); } catch { // Background autosaves already surface their own toast via saveDataRef. } return; } await saveDataRef.current(drawingId, elements, appState, files); }); return saveQueueRef.current; }, [] ); savePreviewRef.current = async (drawingId: string, elements: readonly any[], appState: any, files: any) => { if (!drawingId) return; try { const snapshotFromArgs = Array.isArray(elements) ? elements : []; const snapshotFromRef = latestElementsRef.current ?? []; const candidateSnapshot = hasRenderableElements(snapshotFromArgs) || !hasRenderableElements(snapshotFromRef) ? snapshotFromArgs : snapshotFromRef; const { snapshot: currentSnapshot, prevented: preventedPreviewOverwrite, staleEmptySnapshot: staleEmptyPreview, staleNonRenderableSnapshot: staleNonRenderablePreview, } = resolveSafeSnapshot(candidateSnapshot); const currentFiles = latestFilesRef.current ?? files; const normalizedSnapshot = normalizeImageElementStatus(currentSnapshot, currentFiles); if (suspiciousBlankLoadRef.current && !hasRenderableElements(currentSnapshot)) { console.warn("[Editor] Blocking non-renderable preview due to suspicious blank load", { drawingId, elementCount: currentSnapshot.length, }); return; } if (preventedPreviewOverwrite) { console.warn("[Editor] Prevented stale snapshot preview overwrite", { drawingId, staleEmptyPreview, staleNonRenderablePreview, fallbackElementCount: currentSnapshot.length, }); } const svg = await exportToSvg({ elements: normalizedSnapshot, appState: { ...appState, exportBackground: true, viewBackgroundColor: appState.viewBackgroundColor || '#ffffff', }, files: currentFiles, }); const preview = svg.outerHTML; console.log("[Editor] Saving preview", { drawingId, elementCount: normalizedSnapshot.length, }); await api.updateDrawing(drawingId, { preview }); console.log("[Editor] Preview save complete", { drawingId }); } 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((drawingId, elements, appState, files) => { enqueueSceneSave(drawingId, elements, appState, files); }, 1000), [enqueueSceneSave] // Stable queue wrapper avoids concurrent version conflicts ); // Allow non-hook code (e.g., Excalidraw API wrappers) to trigger debounced saves. debouncedSaveRef.current = debouncedSave; const debouncedSavePreview = useCallback( debounce((drawingId, elements, appState, files) => { if (savePreviewRef.current) { savePreviewRef.current(drawingId, elements, appState, files); } }, 10000), [] ); const debouncedSaveLibrary = useCallback( debounce((items: any[]) => { if (saveLibraryRef.current) { saveLibraryRef.current(items); } }, 1000), [] ); useEffect(() => { return () => { debouncedSave.cancel(); debouncedSavePreview.cancel(); }; }, [debouncedSave, debouncedSavePreview]); 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(); saveQueueRef.current = Promise.resolve(); latestElementsRef.current = []; initialSceneElementsRef.current = []; latestFilesRef.current = {}; lastSyncedFilesRef.current = {}; currentDrawingVersionRef.current = null; lastPersistedElementsRef.current = []; suspiciousBlankLoadRef.current = false; hasSceneChangesSinceLoadRef.current = false; 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 || {}; const hasPreview = typeof data.preview === "string" && data.preview.trim().length > 0; const loadedRenderable = hasRenderableElements(elements); suspiciousBlankLoadRef.current = !loadedRenderable && hasPreview; hasSceneChangesSinceLoadRef.current = false; console.log("[Editor] Loaded drawing", { drawingId: id, elementCount: elements.length, loadedRenderable, hasPreview, version: data.version ?? null, suspiciousBlankLoad: suspiciousBlankLoadRef.current, }); latestElementsRef.current = elements; initialSceneElementsRef.current = elements; latestFilesRef.current = files; lastSyncedFilesRef.current = files; currentDrawingVersionRef.current = typeof data.version === "number" ? data.version : null; lastPersistedElementsRef.current = elements; 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 = []; initialSceneElementsRef.current = []; latestFilesRef.current = {}; lastSyncedFilesRef.current = {}; currentDrawingVersionRef.current = null; lastPersistedElementsRef.current = []; suspiciousBlankLoadRef.current = false; hasSceneChangesSinceLoadRef.current = false; 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 { snapshot: safeElements, prevented, staleEmptySnapshot, staleNonRenderableSnapshot, } = resolveSafeSnapshot(elements); const appState = excalidrawAPI.current.getAppState(); const files = excalidrawAPI.current.getFiles() || {}; latestFilesRef.current = files; if (prevented) { console.warn("[Editor] Prevented stale Ctrl+S snapshot overwrite", { drawingId: id, staleEmptySnapshot, staleNonRenderableSnapshot, candidateElementCount: elements.length, fallbackElementCount: safeElements.length, }); } if (!id) return; await enqueueSceneSave(id, safeElements, appState, files); savePreviewRef.current(id, safeElements, appState, files); toast.success("Saved changes to server"); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [enqueueSceneSave, id, resolveSafeSnapshot]); 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, initialSceneElementsRef.current ); const transientHydrationEmpty = isSuspiciousEmptySnapshot( initialSceneElementsRef.current, allElements ); const transientHydrationNonRenderable = isStaleNonRenderableSnapshot( initialSceneElementsRef.current, allElements ); if (transientHydrationEmpty || transientHydrationNonRenderable) { console.log("[Editor] Skipping transient hydration snapshot", { drawingId: id, elementCount: allElements.length, transientHydrationEmpty, transientHydrationNonRenderable, }); return; } 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, }); } const noFileChanges = Object.keys(getFilesDelta(latestFilesRef.current || {}, currentFiles || {})).length === 0; if (haveSameElements(allElements, latestElementsRef.current) && noFileChanges) { return; } const { prevented: preventedCanvasOverwrite, staleEmptySnapshot: staleEmptyCanvasSnapshot, staleNonRenderableSnapshot: staleNonRenderableCanvasSnapshot, } = resolveSafeSnapshot(allElements); if (preventedCanvasOverwrite) { console.warn("[Editor] Skipping stale non-renderable change", { drawingId: id, elementCount: allElements.length, staleEmptyCanvasSnapshot, staleNonRenderableCanvasSnapshot, }); return; } const hasRenderable = hasRenderableElements(allElements); if (hasRenderable && suspiciousBlankLoadRef.current) { suspiciousBlankLoadRef.current = false; console.log("[Editor] Cleared suspicious blank load guard after renderable edit", { drawingId: id, elementCount: allElements.length, }); } if (isBootstrappingScene.current && !hasRenderable) { console.log("[Editor] Bootstrapping guard active", { drawingId: id, elementCount: allElements.length, }); return; } latestElementsRef.current = allElements; hasSceneChangesSinceLoadRef.current = true; // Trigger Sync (Throttled) broadcastChanges(allElements, currentFiles); const filesSnapshot = currentFiles; latestFilesRef.current = filesSnapshot; // Trigger Fast Save console.log("[Editor] Queueing save", { drawingId: id, elementCount: allElements.length, hasRenderableElements: hasRenderable, }); if (id) { debouncedSave(id, allElements, appState, filesSnapshot); } // Trigger Slow Preview Gen console.log("[Editor] Queueing preview save", { drawingId: id, fileCount: Object.keys(filesSnapshot).length, }); if (id) { debouncedSavePreview(id, allElements, appState, filesSnapshot); } }, [debouncedSave, debouncedSavePreview, broadcastChanges, id, resolveSafeSnapshot]); // 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) { hasSceneChangesSinceLoadRef.current = true; debouncedSaveRef.current(id, latestElementsRef.current, latestAppStateRef.current, nextFiles); if (savePreviewRef.current) { void savePreviewRef.current( id, latestElementsRef.current, latestAppStateRef.current, nextFiles ); } } }, 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); let shouldNavigate = false; // Save drawing and generate preview before navigating try { if (!(excalidrawAPI.current && saveDataRef.current && savePreviewRef.current)) { // If editor API is not ready, allow navigation instead of trapping the user. shouldNavigate = true; } else if (!hasSceneChangesSinceLoadRef.current) { console.log("[Editor] Skipping back-navigation save: no scene changes since load", { drawingId: id, }); shouldNavigate = true; } else if (!id) { shouldNavigate = true; } else { const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); const { snapshot: safeElements, prevented, staleEmptySnapshot, staleNonRenderableSnapshot, } = resolveSafeSnapshot(elements); const appState = excalidrawAPI.current.getAppState(); const files = excalidrawAPI.current.getFiles() || {}; latestFilesRef.current = files; if (prevented) { console.warn("[Editor] Prevented stale back-navigation snapshot overwrite", { drawingId: id, staleEmptySnapshot, staleNonRenderableSnapshot, candidateElementCount: elements.length, fallbackElementCount: safeElements.length, }); } if (suspiciousBlankLoadRef.current && !hasRenderableElements(safeElements)) { console.warn("[Editor] Blocking back-navigation save due to suspicious blank load", { drawingId: id, elementCount: safeElements.length, }); toast.warning("Blank scene detected on load. Skipping save to protect existing data."); shouldNavigate = true; } else { await Promise.all([ enqueueSceneSave(id, safeElements, appState, files, { suppressErrors: false }), savePreviewRef.current(id, safeElements, appState, files) ]); console.log("[Editor] Saved on back navigation", { drawingId: id }); shouldNavigate = true; } } } catch (err) { console.error('Failed to save on back navigation', err); toast.error("Failed to save changes. Please retry before leaving."); } finally { setIsSavingOnLeave(false); } if (shouldNavigate) { 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...'}
)}
); };