diff --git a/backend/src/index.ts b/backend/src/index.ts index 0a4a773..6de2da1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -96,6 +96,22 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => { const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL); console.log("Allowed origins:", allowedOrigins); +const isDev = (process.env.NODE_ENV || "development") !== "production"; +const isLocalDevOrigin = (origin: string): boolean => { + // Allow any localhost/127.0.0.1 port in dev (Vite often picks a free port). + return ( + /^http:\/\/localhost:\d+$/i.test(origin) || + /^http:\/\/127\.0\.0\.1:\d+$/i.test(origin) + ); +}; + +const isAllowedOrigin = (origin?: string): boolean => { + if (!origin) return true; // non-browser clients / same-origin + if (allowedOrigins.includes(origin)) return true; + if (isDev && isLocalDevOrigin(origin)) return true; + return false; +}; + const uploadDir = path.resolve(__dirname, "../uploads"); const moveFile = async (source: string, destination: string) => { @@ -138,7 +154,7 @@ app.set("trust proxy", 1); const httpServer = createServer(app); const io = new Server(httpServer, { cors: { - origin: allowedOrigins, + origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)), credentials: true, }, maxHttpBufferSize: 1e8, @@ -236,7 +252,7 @@ const upload = multer({ app.use( cors({ - origin: allowedOrigins, + origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)), credentials: true, allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"], exposedHeaders: ["x-csrf-token"], @@ -402,7 +418,7 @@ const csrfProtectionMiddleware = ( const refererValue = Array.isArray(referer) ? referer[0] : referer; if (originValue) { - if (!allowedOrigins.includes(originValue)) { + if (!isAllowedOrigin(originValue)) { return res.status(403).json({ error: "CSRF origin mismatch", message: "Origin not allowed", @@ -411,7 +427,7 @@ const csrfProtectionMiddleware = ( } else if (refererValue) { // If no Origin but Referer exists, validate its *origin* (avoid prefix bypass) const refererOrigin = getOriginFromReferer(refererValue); - if (!refererOrigin || !allowedOrigins.includes(refererOrigin)) { + if (!refererOrigin || !isAllowedOrigin(refererOrigin)) { return res.status(403).json({ error: "CSRF referer mismatch", message: "Referer not allowed", diff --git a/e2e/tests/helpers/api.ts b/e2e/tests/helpers/api.ts index 6805e63..918188c 100644 --- a/e2e/tests/helpers/api.ts +++ b/e2e/tests/helpers/api.ts @@ -174,6 +174,37 @@ export async function getDrawing( return (await response.json()) as DrawingRecord; } +export async function updateDrawing( + request: APIRequestContext, + id: string, + data: Partial +): Promise { + const headers = await withCsrfHeaders(request, { "Content-Type": "application/json" }); + + let response = await request.put(`${API_URL}/drawings/${id}`, { + headers, + data, + }); + + if (!response.ok() && response.status() === 403) { + await refreshCsrfInfo(request); + const retryHeaders = await withCsrfHeaders(request, { + "Content-Type": "application/json", + }); + response = await request.put(`${API_URL}/drawings/${id}`, { + headers: retryHeaders, + data, + }); + } + + if (!response.ok()) { + const text = await response.text(); + throw new Error(`Failed to update drawing ${id}: ${response.status()} ${text}`); + } + + return (await response.json()) as DrawingRecord; +} + export async function deleteDrawing( request: APIRequestContext, id: string diff --git a/e2e/tests/image-collab.spec.ts b/e2e/tests/image-collab.spec.ts new file mode 100644 index 0000000..611fcd8 --- /dev/null +++ b/e2e/tests/image-collab.spec.ts @@ -0,0 +1,252 @@ +import { test, expect, type Page, type BrowserContext } from "@playwright/test"; +import { createDrawing, deleteDrawing, getDrawing, updateDrawing } from "./helpers/api"; + +/** + * Regression tests for: + * - Issue #25: pasted image doesn't load in other tabs + * - Follow-up: deleting the image in one tab should remove it from all tabs + * + * NOTE: + * We drive the editor via Excalidraw's API (exposed in dev/test builds) to make + * the test deterministic and to specifically model the async "element first, + * file data later" behavior seen with paste/import. + */ + +const waitForEditorReady = async (page: Page) => { + await page.goto(page.url() || "/"); + // Excalidraw renders a canvas; this is our "loaded" signal. + await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 }); + await page.waitForFunction(() => { + // @ts-expect-error - injected in dev build + return !!(window as any).__EXCALIDASH_EXCALIDRAW_API__; + }); +}; + +const openEditorTab = async (context: BrowserContext, drawingId: string) => { + const page = await context.newPage(); + await page.goto(`/editor/${drawingId}`); + await page.waitForSelector("[class*='excalidraw'], canvas", { timeout: 15000 }); + await page.waitForFunction(() => { + // @ts-expect-error - injected in dev build + return !!(window as any).__EXCALIDASH_EXCALIDRAW_API__; + }); + // Wait for socket connection (critical for realtime sync assertions). + await page.waitForFunction(() => { + // @ts-expect-error - injected in dev build + return (window as any).__EXCALIDASH_SOCKET_STATUS__?.connected === true; + }); + return page; +}; + +const waitForFileInEditor = async (page: Page, fileId: string) => { + // Excalidraw may clear `dataURL` from in-memory files for perf/memory, + // so the stable signal is that the file entry exists. + const timeoutMs = 30000; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const ok = await page.evaluate((id) => { + const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__; + const files = api?.getFiles?.() || {}; + const entry = files?.[id]; + return !!entry && typeof entry.mimeType === "string"; + }, fileId); + if (ok) return; + await new Promise((r) => setTimeout(r, 200)); + } + throw new Error(`Timed out waiting for file ${fileId} to exist in editor`); +}; + +const injectImageElementThenFile = async (page: Page) => { + return await page.evaluate(async () => { + const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__; + if (!api) throw new Error("Missing __EXCALIDASH_EXCALIDRAW_API__"); + + const bytes = crypto.getRandomValues(new Uint8Array(20)); + const fileId = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + const elementId = `img_${Math.random().toString(36).slice(2)}`; + + // Tiny PNG data URL + const dataURL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAIElEQVR42mP8z8Dwn4EIwDiqgWjAqIGhBo4aGAAAcO0Gg+o1P8oAAAAASUVORK5CYII="; + + const now = Date.now(); + const element = { + id: elementId, + type: "image", + x: 120, + y: 120, + width: 240, + height: 240, + angle: 0, + strokeColor: "#1e1e1e", + backgroundColor: "transparent", + fillStyle: "solid", + strokeWidth: 1, + strokeStyle: "solid", + roundness: null, + roughness: 0, + opacity: 100, + groupIds: [], + frameId: null, + seed: Math.floor(Math.random() * 2 ** 31), + version: 1, + versionNonce: Math.floor(Math.random() * 2 ** 31), + isDeleted: false, + boundElements: null, + link: null, + locked: false, + index: "a1", + updated: now, + status: "pending", + fileId, + scale: [1, 1], + crop: null, + }; + + const before = api.getSceneElementsIncludingDeleted(); + api.updateScene({ elements: [...before, element] }); + + // Simulate async file arrival (paste/import often behaves like this) + await new Promise((r) => setTimeout(r, 600)); + api.addFiles({ + [fileId]: { + id: fileId, + mimeType: "image/png", + dataURL, + created: Date.now(), + lastRetrieved: Date.now(), + }, + }); + + return { fileId, elementId }; + }); +}; + +const waitForElementPresent = async (page: Page, elementId: string) => { + await page.waitForFunction( + (id) => { + const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__; + const els = api?.getSceneElementsIncludingDeleted?.() || []; + const el = els.find((e: any) => e?.id === id); + return !!el && el.isDeleted !== true; + }, + elementId, + { timeout: 15000 } + ); +}; + +const waitForElementDeletedEverywhere = async (page: Page, elementId: string) => { + await page.waitForFunction( + (id) => { + const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__; + const els = api?.getSceneElementsIncludingDeleted?.() || []; + const el = els.find((e: any) => e?.id === id); + return !!el && el.isDeleted === true; + }, + elementId, + { timeout: 15000 } + ); +}; + +test.describe("Issue #25 - image sync + deletion across tabs", () => { + const createdDrawingIds: string[] = []; + + test.afterEach(async ({ request }) => { + for (const id of createdDrawingIds) { + try { + await deleteDrawing(request, id); + } catch { + // ignore cleanup errors + } + } + createdDrawingIds.length = 0; + }); + + test("image added in tab1 appears in tab2 and tab3; deletion propagates to all tabs", async ({ + browser, + request, + }) => { + test.setTimeout(120000); + const drawing = await createDrawing(request, { + name: `Issue25_ImageCollab_${Date.now()}`, + elements: [], + files: {}, + }); + createdDrawingIds.push(drawing.id); + + const context = await browser.newContext(); + const page1 = await openEditorTab(context, drawing.id); + const page2 = await openEditorTab(context, drawing.id); + + // Create the image in tab1 (element first, file later) to model paste/import. + const { fileId, elementId } = await injectImageElementThenFile(page1); + + // Tab2 should receive the element and the file in real-time. + await waitForElementPresent(page2, elementId); + await waitForFileInEditor(page2, fileId); + + // Persist the current state explicitly (ensures tab3 loads it even if the editor didn't auto-save). + const snapshot = await page1.evaluate(() => { + const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__; + const elements = api.getSceneElementsIncludingDeleted(); + const files = api.getFiles?.() || {}; + const appState = api.getAppState?.() || {}; + return { + elements, + files, + appState: { + viewBackgroundColor: appState.viewBackgroundColor ?? "#ffffff", + gridSize: appState.gridSize ?? null, + }, + }; + }); + await updateDrawing(request, drawing.id, snapshot); + + // Open tab3 and ensure it loads (persistence path) + const page3 = await openEditorTab(context, drawing.id); + await waitForFileInEditor(page3, fileId); + + // Force the "tab2 doesn't disappear" repro: keep the image selected in tab2. + await page2.evaluate((id) => { + const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__; + const appState = api.getAppState(); + api.updateScene({ + appState: { + ...appState, + selectedElementIds: { ...(appState.selectedElementIds || {}), [id]: true }, + }, + }); + }, elementId); + + // Delete the image from tab1 (programmatic delete to ensure broadcast) + await page1.evaluate((id) => { + const api = (window as any).__EXCALIDASH_EXCALIDRAW_API__; + const els = api.getSceneElementsIncludingDeleted(); + const target = els.find((e: any) => e?.id === id); + if (!target) throw new Error("Target element not found"); + const updated = { + ...target, + isDeleted: true, + version: (target.version ?? 0) + 1, + versionNonce: Math.floor(Math.random() * 2 ** 31), + updated: Date.now(), + }; + api.updateScene({ elements: els.map((e: any) => (e.id === id ? updated : e)) }); + }, elementId); + + // All tabs should converge to deleted + await waitForElementDeletedEverywhere(page2, elementId); + await waitForElementDeletedEverywhere(page3, elementId); + + // Also verify persistence layer captured the file (tab3 load case) + const persisted = await getDrawing(request, drawing.id); + const persistedFile = persisted.files?.[fileId]; + expect(typeof persistedFile?.dataURL).toBe("string"); + expect((persistedFile?.dataURL || "").length).toBeGreaterThan(0); + + await context.close(); + }); +}); + diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index f498ba7..d468204 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -37,6 +37,44 @@ const haveSameElements = (a: readonly any[] = [], b: readonly any[] = []) => { return true; }; +const buildFileSignature = (file: any): string => { + const mimeType = typeof file?.mimeType === "string" ? file.mimeType : ""; + const id = typeof file?.id === "string" ? file.id : ""; + const dataURL = typeof file?.dataURL === "string" ? file.dataURL : ""; + // Avoid keeping the whole dataURL for comparisons; use a cheap signature. + const prefix = dataURL.slice(0, 32); + const suffix = dataURL.slice(-32); + return `${id}|${mimeType}|${dataURL.length}|${prefix}|${suffix}`; +}; + +const getFilesDelta = ( + previous: Record, + next: Record +): Record => { + const delta: Record = {}; + const prev = previous || {}; + const nxt = next || {}; + + for (const fileId of Object.keys(nxt)) { + const nextFile = nxt[fileId]; + const nextHasDataUrl = typeof nextFile?.dataURL === "string" && nextFile.dataURL.length > 0; + // Only sync files that actually have data; otherwise other tabs can't render yet. + if (!nextHasDataUrl) continue; + + const prevFile = prev[fileId]; + if (!prevFile) { + delta[fileId] = nextFile; + continue; + } + + if (buildFileSignature(prevFile) !== buildFileSignature(nextFile)) { + delta[fileId] = nextFile; + } + } + + return delta; +}; + const UIOptions = { canvasActions: { saveToActiveFile: false, @@ -50,7 +88,7 @@ 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(''); @@ -64,7 +102,7 @@ export const Editor: React.FC = () => { document.title = 'ExcaliDash'; }; }, [drawingName]); - + const [peers, setPeers] = useState([]); const [me] = useState(getUserIdentity()); const [isReady, setIsReady] = useState(false); @@ -79,6 +117,39 @@ export const Editor: React.FC = () => { 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, { @@ -110,13 +181,26 @@ export const Editor: React.FC = () => { 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; + // 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 @@ -125,7 +209,7 @@ export const Editor: React.FC = () => { const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []); cursorBuffer.current.forEach((data, userId) => { - collaborators.set(userId, data); + collaborators.set(userId, data); }); cursorBuffer.current.clear(); @@ -150,18 +234,18 @@ export const Editor: React.FC = () => { }); socket.on('cursor-move', (data: any) => { - 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, - }); + 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[] }) => { + socket.on('element-update', ({ elements, files }: { elements: any[]; files?: Record }) => { if (!excalidrawAPI.current) return; isSyncing.current = true; @@ -169,7 +253,11 @@ export const Editor: React.FC = () => { const currentAppState = excalidrawAPI.current.getAppState(); const mySelectedIds = currentAppState.selectedElementIds || {}; - const validRemoteElements = elements.filter((el: any) => !mySelectedIds[el.id]); + // 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); @@ -178,11 +266,28 @@ export const Editor: React.FC = () => { 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 }); }; @@ -224,14 +329,39 @@ export const Editor: React.FC = () => { 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(() => { @@ -242,7 +372,7 @@ export const Editor: React.FC = () => { const params = new URLSearchParams(hash.slice(1)); // Remove the leading # const libraryUrl = params.get('addLibrary'); - + if (!libraryUrl) return; const importLibraryFromUrl = async () => { @@ -371,15 +501,17 @@ export const Editor: React.FC = () => { } }; - - const debouncedSave = useCallback( - debounce((elements, appState) => { - if (saveDataRef.current) { - saveDataRef.current(elements, appState); - } - }, 1000), - [] // Empty dependency array = Stable across renders - ); + + 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) { @@ -399,9 +531,9 @@ export const Editor: React.FC = () => { ); const broadcastChanges = useCallback( - throttle((elements: readonly any[]) => { + throttle((elements: readonly any[], currentFiles?: Record) => { if (!socketRef.current || !id) return; - + const changes: any[] = []; elements.forEach((el) => { @@ -410,11 +542,24 @@ export const Editor: React.FC = () => { recordElementVersion(el); } }); - - if (changes.length > 0) { + + 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, + elements: changes.length > 0 ? changes : [], + files: shouldSyncFiles ? filesDelta : undefined, userId: me.id }); } @@ -428,6 +573,7 @@ export const Editor: React.FC = () => { elementVersionMap.current.clear(); latestElementsRef.current = []; latestFilesRef.current = {}; + lastSyncedFilesRef.current = {}; excalidrawAPI.current = null; setIsReady(false); setIsSceneLoading(true); @@ -453,7 +599,8 @@ export const Editor: React.FC = () => { const files = data.files || {}; latestElementsRef.current = elements; latestFilesRef.current = files; - + lastSyncedFilesRef.current = files; + elements.forEach((el: any) => { recordElementVersion(el); }); @@ -465,6 +612,9 @@ export const Editor: React.FC = () => { 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, @@ -478,6 +628,7 @@ export const Editor: React.FC = () => { toast.error("Failed to load drawing"); latestElementsRef.current = []; latestFilesRef.current = {}; + lastSyncedFilesRef.current = {}; setInitialData(buildEmptyScene()); } finally { setIsSceneLoading(false); @@ -507,17 +658,24 @@ export const Editor: React.FC = () => { return () => window.removeEventListener('keydown', handleKeyDown); }, []); - const handleCanvasChange = useCallback((elements: readonly any[], appState: any) => { + 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() + const allElements = excalidrawAPI.current + ? excalidrawAPI.current.getSceneElementsIncludingDeleted() : elements; if (!hasHydratedInitialScene.current) { @@ -551,7 +709,7 @@ export const Editor: React.FC = () => { } // Trigger Sync (Throttled) - broadcastChanges(allElements); + broadcastChanges(allElements, currentFiles); // Trigger Fast Save console.log("[Editor] Queueing save", { @@ -562,15 +720,38 @@ export const Editor: React.FC = () => { debouncedSave(allElements, appState); // Trigger Slow Preview Gen - const files = excalidrawAPI.current?.getFiles() || {}; - latestFilesRef.current = files; + const filesSnapshot = currentFiles; + latestFilesRef.current = filesSnapshot; console.log("[Editor] Queueing preview save", { drawingId: id, - fileCount: Object.keys(files).length, + fileCount: Object.keys(filesSnapshot).length, }); - debouncedSavePreview(allElements, appState, files); + 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) { @@ -624,8 +805,8 @@ export const Editor: React.FC = () => {
-
- +
{/* Download Button */}