diff --git a/.gitignore b/.gitignore index 600b6b8..fcda649 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ frontend/node_modules .DS_Store +backend/prisma/*.db \ No newline at end of file diff --git a/backend/prisma/dev.db b/backend/prisma/dev.db deleted file mode 100644 index 3ccc57f..0000000 Binary files a/backend/prisma/dev.db and /dev/null differ diff --git a/backend/src/index.ts b/backend/src/index.ts index 169513f..6751d0a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -148,12 +148,26 @@ app.get("/drawings", async (req, res) => { app.get("/drawings/:id", async (req, res) => { try { const { id } = req.params; + console.log("[API] Fetching drawing", { id }); const drawing = await prisma.drawing.findUnique({ where: { id } }); if (!drawing) { + console.warn("[API] Drawing not found", { id }); return res.status(404).json({ error: "Drawing not found" }); } + console.log("[API] Returning drawing", { + id, + elementCount: (() => { + try { + const parsed = JSON.parse(drawing.elements); + return Array.isArray(parsed) ? parsed.length : null; + } catch (_err) { + return null; + } + })(), + }); + res.json({ ...drawing, elements: JSON.parse(drawing.elements), @@ -195,6 +209,15 @@ app.put("/drawings/:id", async (req, res) => { const { id } = req.params; const { name, elements, appState, collectionId, preview } = req.body; + console.log("[API] Updating drawing", { + id, + hasElements: elements !== undefined, + elementCount: + elements && Array.isArray(elements) ? elements.length : undefined, + hasAppState: appState !== undefined, + hasPreview: preview !== undefined, + }); + const data: any = { version: { increment: 1 }, }; @@ -210,6 +233,18 @@ app.put("/drawings/:id", async (req, res) => { data, }); + console.log("[API] Update complete", { + id, + storedElementCount: (() => { + try { + const parsed = JSON.parse(updatedDrawing.elements); + return Array.isArray(parsed) ? parsed.length : null; + } catch (_err) { + return null; + } + })(), + }); + res.json({ ...updatedDrawing, elements: JSON.parse(updatedDrawing.elements), diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 7c239e0..b563034 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -22,6 +22,20 @@ interface ElementVersionInfo { 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: { @@ -41,6 +55,7 @@ export const Editor: React.FC = () => { 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()); @@ -48,9 +63,14 @@ export const Editor: React.FC = () => { 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, { @@ -69,6 +89,13 @@ export const Editor: React.FC = () => { return previous.version !== nextVersion || previous.versionNonce !== nextNonce; }, []); + useEffect(() => { + isUnmounting.current = false; + return () => { + isUnmounting.current = true; + }; + }, []); + useEffect(() => { if (!id || !isReady) return; @@ -146,6 +173,7 @@ export const Editor: React.FC = () => { }); excalidrawAPI.current.updateScene({ elements: mergedElements }); + latestElementsRef.current = mergedElements; isSyncing.current = false; }); @@ -200,16 +228,26 @@ export const Editor: React.FC = () => { setIsReady(true); }, []); + const buildEmptyScene = useCallback(() => ({ + elements: [], + appState: { + viewBackgroundColor: '#ffffff', + gridSize: null, + collaborators: new Map(), + }, + 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: any, appState: any) => Promise>(null); - const savePreviewRef = useRef<(elements: any, appState: any, files: any) => Promise>(null); + 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, appState) => { + saveDataRef.current = async (elements: readonly any[], appState: any) => { if (!id) return; try { @@ -217,34 +255,56 @@ export const Editor: React.FC = () => { viewBackgroundColor: appState.viewBackgroundColor, gridSize: appState.gridSize, }; - - await api.updateDrawing(id, { - elements, + + 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, + }); + + 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, appState, files) => { + 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, + elements: currentSnapshot, appState: { ...appState, exportBackground: true, viewBackgroundColor: appState.viewBackgroundColor || '#ffffff', }, - files, + 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); } @@ -298,33 +358,57 @@ export const Editor: React.FC = () => { // 2. DATA LOADING // ------------------------------------------------------------------ useEffect(() => { + isBootstrappingScene.current = true; + hasHydratedInitialScene.current = false; + elementVersionMap.current.clear(); + latestElementsRef.current = []; + latestFilesRef.current = null; + excalidrawAPI.current = null; + setIsReady(false); + setIsSceneLoading(true); + setInitialData(null); + const loadData = async () => { - if (!id) return; + if (!id) { + setInitialData(buildEmptyScene()); + setIsSceneLoading(false); + return; + } try { const data = await api.getDrawing(id); setDrawingName(data.name); const elements = convertToExcalidrawElements(data.elements || []); + latestElementsRef.current = elements; + latestFilesRef.current = null; - // Initialize version tracking with loaded data 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: { - ...data.appState, - collaborators: new Map(), - }, + appState: hydratedAppState, scrollToContent: true, }); } catch (err) { console.error('Failed to load drawing', err); + toast.error("Failed to load drawing"); + setInitialData(buildEmptyScene()); + } finally { + setIsSceneLoading(false); } }; loadData(); - }, [id, recordElementVersion]); + }, [id, recordElementVersion, buildEmptyScene]); // ------------------------------------------------------------------ // 3. HANDLERS @@ -336,9 +420,11 @@ export const Editor: React.FC = () => { if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) { - const elements = excalidrawAPI.current.getSceneElements(); + const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted(); const appState = excalidrawAPI.current.getAppState(); const files = excalidrawAPI.current.getFiles() || null; + latestElementsRef.current = elements; + latestFilesRef.current = files; // Call save immediately, bypassing debounce await saveDataRef.current(elements, appState); // Also update preview @@ -352,6 +438,11 @@ export const Editor: React.FC = () => { }, []); 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; @@ -361,14 +452,54 @@ export const Editor: React.FC = () => { ? 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() || null; + latestFilesRef.current = files; + console.log("[Editor] Queueing preview save", { + drawingId: id, + fileCount: files ? Object.keys(files).length : 0, + }); debouncedSavePreview(allElements, appState, files); }, [debouncedSave, debouncedSavePreview, broadcastChanges]); @@ -456,14 +587,23 @@ export const Editor: React.FC = () => {
- + {initialData ? ( + + ) : ( +
+ + {isSceneLoading ? 'Loading drawing...' : 'Preparing canvas...'} + +
+ )}
diff --git a/frontend/src/utils/sync.ts b/frontend/src/utils/sync.ts index cdaa154..38ecf49 100644 --- a/frontend/src/utils/sync.ts +++ b/frontend/src/utils/sync.ts @@ -15,7 +15,7 @@ export const reconcileElements = ( const getVersionNonce = (element: any) => element?.versionNonce ?? 0; const getUpdated = (element: any) => { const value = element?.updated; - return typeof value === 'number' ? value : Number(value) || 0; + return typeof value === "number" ? value : Number(value) || 0; }; remoteElements.forEach((remoteEl) => {