Files
ExcaliDash/frontend/src/pages/Editor.tsx
T
Zimeng Xiong f462b2e288 minor UI fixes
2026-02-06 21:18:10 -08:00

1074 lines
37 KiB
TypeScript

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';
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;
};
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<string, any>,
next: Record<string, any>
): Record<string, any> => {
const delta: Record<string, any> = {};
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,
loadScene: false,
export: { saveFileToDisk: false },
toggleTheme: true,
},
};
const getInitialsFromName = (name: string): string => {
const trimmed = name.trim();
if (!trimmed) return 'U';
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
return trimmed.slice(0, 2).toUpperCase();
};
// Helper function to generate a color from a string (consistent hash)
const getColorFromString = (str: string): string => {
const COLORS = [
"#ef4444", "#f97316", "#f59e0b", "#84cc16", "#22c55e", "#10b981",
"#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6",
"#a855f7", "#d946ef", "#ec4899", "#f43f5e",
];
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return COLORS[Math.abs(hash) % COLORS.length];
};
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<any>(null);
const [isSceneLoading, setIsSceneLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(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<typeof setTimeout> | 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<UserIdentity>(() => {
if (user) {
return {
id: user.id,
name: user.name,
initials: getInitialsFromName(user.name),
color: getColorFromString(user.id),
};
}
return getUserIdentity();
});
const [peers, setPeers] = useState<Peer[]>([]);
const [isReady, setIsReady] = useState(false);
const socketRef = useRef<Socket | null>(null);
const lastCursorEmit = useRef<number>(0);
const elementVersionMap = useRef<Map<string, ElementVersionInfo>>(new Map());
const isBootstrappingScene = useRef(true);
const hasHydratedInitialScene = useRef(false);
const isUnmounting = useRef(false);
const isSyncing = useRef(false);
const cursorBuffer = useRef<Map<string, any>>(new Map());
const animationFrameId = useRef<number>(0);
const latestElementsRef = useRef<readonly any[]>([]);
const latestFilesRef = useRef<any>(null);
const lastSyncedFilesRef = useRef<Record<string, any>>({});
const latestAppStateRef = useRef<any>(null);
const debouncedSaveRef = useRef<((elements: readonly any[], appState: any) => void) | null>(null);
const emitFilesDeltaIfNeeded = useCallback(
(nextFiles: Record<string, any>) => {
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<string, any> }) => {
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<any>(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<string, any>) => {
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<void>) | null>(null);
const savePreviewRef = useRef<((elements: readonly any[], appState: any, files: any) => Promise<void>) | null>(null);
const saveLibraryRef = useRef<((items: any[]) => Promise<void>) | 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<string, any>) => {
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<string, any>) => {
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 (
<div className="h-screen flex flex-col bg-white dark:bg-neutral-950 overflow-hidden">
<header
className={clsx(
"h-14 bg-white dark:bg-neutral-900 border-b border-gray-200 dark:border-neutral-800 flex items-center px-4 justify-between z-10 fixed top-0 left-0 right-0 transition-transform duration-300",
isHeaderVisible ? "translate-y-0" : "-translate-y-full"
)}
>
<div className="flex items-center gap-4">
<button
onClick={handleBackClick}
disabled={isSavingOnLeave}
className={`flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded-full text-gray-600 dark:text-gray-300 disabled:opacity-50 disabled:cursor-wait transition-all duration-200 ${isSavingOnLeave ? 'pr-4' : ''}`}
>
{isSavingOnLeave ? (
<>
<Loader2 size={20} className="animate-spin" />
<span className="text-sm font-medium">Saving changes...</span>
</>
) : (
<ArrowLeft size={20} />
)}
</button>
{isRenaming ? (
<form onSubmit={handleRenameSubmit}>
<input
autoFocus
type="text"
value={newName}
onChange={(e) => 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` }}
/>
</form>
) : (
<h1
className="font-medium text-gray-900 dark:text-white px-2 py-1 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded cursor-text"
onDoubleClick={() => { setNewName(drawingName); setIsRenaming(true); }}
>
{drawingName}
</h1>
)}
</div>
<div className="flex items-center gap-3">
{/* Auto-hide Toggle */}
<button
onClick={() => {
setAutoHideEnabled(!autoHideEnabled);
if (!autoHideEnabled) {
setIsHeaderVisible(true);
}
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded-lg text-gray-600 dark:text-gray-300 transition-colors"
title={autoHideEnabled ? "Disable auto-hide" : "Enable auto-hide"}
>
{autoHideEnabled ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</button>
<div className="h-6 w-px bg-gray-300 dark:bg-gray-700" />
{/* Download Button */}
<button
onClick={() => {
if (excalidrawAPI.current) {
const elements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
const appState = excalidrawAPI.current.getAppState();
const files = excalidrawAPI.current.getFiles() || {};
exportFromEditor(drawingName, elements, appState, files);
toast.success('Drawing exported');
}
}}
className="p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded-lg text-gray-600 dark:text-gray-300 transition-colors"
title="Export drawing"
>
<Download size={20} />
</button>
<div className="h-6 w-px bg-gray-300 dark:bg-gray-700" />
<div className="flex items-center">
<div className="relative group">
<div
className="w-9 h-9 rounded-xl flex items-center justify-center text-sm font-bold text-white shadow-sm"
style={{ backgroundColor: me.color }}
>
{me.initials}
</div>
<div className="absolute top-full mt-2 right-0 bg-gray-900 text-white text-xs py-1 px-2 rounded whitespace-nowrap z-50 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
{me.name} (You)
</div>
</div>
<div className="h-6 w-px bg-gray-300 dark:bg-gray-700 mx-2" />
<div className="flex items-center gap-2">
{peers.map(peer => (
<div
key={peer.id}
className="relative group"
>
<div
className={`w-9 h-9 rounded-xl flex items-center justify-center text-sm font-bold text-white shadow-sm transition-all duration-300 ${!peer.isActive ? 'opacity-30 grayscale' : ''}`}
style={{ backgroundColor: peer.color }}
>
{peer.initials}
</div>
<div className="absolute top-full mt-2 right-0 bg-gray-900 text-white text-xs py-1 px-2 rounded whitespace-nowrap z-50 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">
{peer.name}
</div>
</div>
))}
</div>
</div>
</div>
</header>
<div
className="flex-1 w-full relative transition-all duration-300"
style={{
height: isHeaderVisible ? 'calc(100vh - 3.5rem)' : '100vh',
marginTop: isHeaderVisible ? '3.5rem' : '0'
}}
>
{loadError ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-white dark:bg-neutral-950 px-6">
<div className="text-center">
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">
Unable to open drawing
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
{loadError}
</p>
</div>
<button
onClick={() => navigate('/')}
className="px-4 py-2 rounded-lg border-2 border-black dark:border-neutral-700 bg-white dark:bg-neutral-900 text-gray-900 dark:text-gray-100 font-semibold hover:bg-gray-50 dark:hover:bg-neutral-800 transition-colors"
>
Back to dashboard
</button>
</div>
) : initialData ? (
<Excalidraw
key={id}
theme={theme === 'dark' ? 'dark' : 'light'}
initialData={initialData}
onChange={handleCanvasChange}
onPointerUpdate={onPointerUpdate}
onLibraryChange={handleLibraryChange}
excalidrawAPI={setExcalidrawAPI}
UIOptions={UIOptions}
/>
) : (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-gray-500 dark:text-gray-400">
<span className="text-sm font-medium">
{isSceneLoading ? 'Loading drawing...' : 'Preparing canvas...'}
</span>
</div>
)}
<Toaster position="bottom-center" />
</div>
</div>
);
};