working live collab

This commit is contained in:
Zimeng Xiong
2025-11-21 22:06:12 -08:00
parent 0878b5e87f
commit 9ee9d6ccfe
9 changed files with 960 additions and 37 deletions
+301 -27
View File
@@ -4,10 +4,34 @@ import { ArrowLeft } from 'lucide-react';
import { Excalidraw, convertToExcalidrawElements, exportToSvg } from '@excalidraw/excalidraw';
import '@excalidraw/excalidraw/index.css';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import { Toaster, toast } from 'sonner';
import { io, Socket } from 'socket.io-client';
import { getUserIdentity } from '../utils/identity';
import { reconcileElements } from '../utils/sync';
import type { UserIdentity } from '../utils/identity';
import * as api from '../api';
import { useTheme } from '../context/ThemeContext';
interface Peer extends UserIdentity {
isActive: boolean;
}
interface ElementVersionInfo {
version: number;
versionNonce: number;
}
// Move UIOptions outside to prevent re-creation on every render
const UIOptions = {
canvasActions: {
saveToActiveFile: false,
loadScene: false,
export: { saveFileToDisk: false },
toggleTheme: true,
},
};
export const Editor: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -18,8 +42,163 @@ export const Editor: React.FC = () => {
const [newName, setNewName] = useState('');
const [initialData, setInitialData] = useState<any>(null);
const [peers, setPeers] = useState<Peer[]>([]);
const [me] = useState(getUserIdentity());
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 isSyncing = useRef(false);
const cursorBuffer = useRef<Map<string, any>>(new Map());
const animationFrameId = useRef<number>(0);
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(() => {
if (!id || !isReady) return;
const socket = io(import.meta.env.VITE_API_URL || 'http://localhost:8000', {
transports: ['websocket'],
});
socketRef.current = socket;
socket.emit('join-room', { drawingId: id, user: me });
// Start the render loop for cursors
const renderLoop = () => {
if (cursorBuffer.current.size > 0 && excalidrawAPI.current) {
const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []);
cursorBuffer.current.forEach((data, userId) => {
collaborators.set(userId, data);
});
cursorBuffer.current.clear();
excalidrawAPI.current.updateScene({ collaborators });
}
animationFrameId.current = requestAnimationFrame(renderLoop);
};
renderLoop();
socket.on('presence-update', (users: Peer[]) => {
setPeers(users.filter(u => u.id !== me.id));
// Update collaborators map to remove inactive users
if (excalidrawAPI.current) {
const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []);
users.forEach(user => {
if (!user.isActive && user.id !== me.id) {
collaborators.delete(user.id);
}
});
excalidrawAPI.current.updateScene({ collaborators });
}
});
socket.on('cursor-move', (data: any) => {
// Just buffer the data
cursorBuffer.current.set(data.userId, {
pointer: data.pointer,
button: data.button || 'up',
selectedElementIds: data.selectedElementIds || {},
username: data.username,
avatarUrl: data.avatarUrl,
color: { background: data.color, stroke: data.color },
id: data.userId,
});
});
socket.on('element-update', ({ elements }: { elements: any[] }) => {
if (!excalidrawAPI.current) return;
isSyncing.current = true;
// 3. THE SELECTION GUARD (Fixes Dragging/Snap-back)
// Get IDs of elements YOU are currently holding
const currentAppState = excalidrawAPI.current.getAppState();
const mySelectedIds = currentAppState.selectedElementIds || {};
// Filter out updates for elements you are currently dragging
// This prevents the server from pulling the object out of your hand
const validRemoteElements = elements.filter((el: any) => !mySelectedIds[el.id]);
const localElements = excalidrawAPI.current.getSceneElementsIncludingDeleted();
const mergedElements = reconcileElements(localElements, validRemoteElements);
// Update version map with remote versions to avoid echoing
validRemoteElements.forEach((el: any) => {
recordElementVersion(el);
});
excalidrawAPI.current.updateScene({ elements: mergedElements });
isSyncing.current = false;
});
// Activity Tracking
const handleActivity = (isActive: boolean) => {
socket.emit('user-activity', { drawingId: id, isActive });
};
const onFocus = () => handleActivity(true);
const onBlur = () => handleActivity(false);
const onMouseEnter = () => handleActivity(true);
const onMouseLeave = () => handleActivity(false);
window.addEventListener('focus', onFocus);
window.addEventListener('blur', onBlur);
document.addEventListener('mouseenter', onMouseEnter);
document.addEventListener('mouseleave', onMouseLeave);
return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('blur', onBlur);
document.removeEventListener('mouseenter', onMouseEnter);
document.removeEventListener('mouseleave', onMouseLeave);
socket.off('presence-update');
socket.off('cursor-move');
socket.off('element-update');
socket.disconnect();
cancelAnimationFrame(animationFrameId.current);
};
}, [id, me, isReady, recordElementVersion]);
const onPointerUpdate = useCallback((payload: any) => {
const now = Date.now();
if (now - lastCursorEmit.current > 50 && socketRef.current) {
socketRef.current.emit('cursor-move', {
pointer: payload.pointer,
button: payload.button,
username: me.name,
userId: me.id,
drawingId: id,
color: me.color
});
lastCursorEmit.current = now;
}
}, [id, me]);
// Refs for API interaction
const excalidrawAPI = useRef<any>(null);
const setExcalidrawAPI = useCallback((api: any) => {
excalidrawAPI.current = api;
setIsReady(true);
}, []);
// ------------------------------------------------------------------
// 1. STABLE SAVE LOGIC (The Fix)
@@ -27,6 +206,7 @@ export const Editor: React.FC = () => {
// doesn't need to be recreated on every render.
// ------------------------------------------------------------------
const saveDataRef = useRef<(elements: any, appState: any) => Promise<void>>(null);
const savePreviewRef = useRef<(elements: any, appState: any, files: any) => Promise<void>>(null);
// Update the ref on every render to ensure it has access to the latest props/state
saveDataRef.current = async (elements, appState) => {
@@ -37,9 +217,22 @@ export const Editor: React.FC = () => {
viewBackgroundColor: appState.viewBackgroundColor,
gridSize: appState.gridSize,
};
await api.updateDrawing(id, {
elements,
appState: persistableAppState,
});
} catch (err) {
console.error('Failed to save drawing', err);
toast.error("Failed to save changes");
}
};
savePreviewRef.current = async (elements, appState, files) => {
if (!id) return;
try {
// Generate preview
const files = excalidrawAPI.current?.getFiles() || null;
const svg = await exportToSvg({
elements,
appState: {
@@ -50,15 +243,10 @@ export const Editor: React.FC = () => {
files,
});
const preview = svg.outerHTML;
await api.updateDrawing(id, {
elements,
appState: persistableAppState,
preview,
});
await api.updateDrawing(id, { preview });
} catch (err) {
console.error('Failed to save drawing', err);
toast.error("Failed to save changes");
console.error('Failed to save preview', err);
}
};
@@ -73,6 +261,39 @@ export const Editor: React.FC = () => {
[] // Empty dependency array = Stable across renders
);
const debouncedSavePreview = useCallback(
debounce((elements, appState, files) => {
if (savePreviewRef.current) {
savePreviewRef.current(elements, appState, files);
}
}, 10000),
[]
);
const broadcastChanges = useCallback(
throttle((elements: readonly any[]) => {
if (!socketRef.current || !id) return;
const changes: any[] = [];
elements.forEach((el) => {
if (hasElementChanged(el)) {
changes.push(el);
recordElementVersion(el);
}
});
if (changes.length > 0) {
socketRef.current.emit('element-update', {
drawingId: id,
elements: changes,
userId: me.id
});
}
}, 100, { leading: true, trailing: true }),
[id, hasElementChanged, recordElementVersion]
);
// ------------------------------------------------------------------
// 2. DATA LOADING
// ------------------------------------------------------------------
@@ -83,8 +304,15 @@ export const Editor: React.FC = () => {
const data = await api.getDrawing(id);
setDrawingName(data.name);
const elements = convertToExcalidrawElements(data.elements || []);
// Initialize version tracking with loaded data
elements.forEach((el: any) => {
recordElementVersion(el);
});
setInitialData({
elements: convertToExcalidrawElements(data.elements || []),
elements,
appState: {
...data.appState,
collaborators: new Map(),
@@ -96,7 +324,7 @@ export const Editor: React.FC = () => {
}
};
loadData();
}, [id]);
}, [id, recordElementVersion]);
// ------------------------------------------------------------------
// 3. HANDLERS
@@ -107,11 +335,14 @@ export const Editor: React.FC = () => {
const handleKeyDown = async (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
e.preventDefault();
if (excalidrawAPI.current && saveDataRef.current) {
if (excalidrawAPI.current && saveDataRef.current && savePreviewRef.current) {
const elements = excalidrawAPI.current.getSceneElements();
const appState = excalidrawAPI.current.getAppState();
const files = excalidrawAPI.current.getFiles() || null;
// Call save immediately, bypassing debounce
await saveDataRef.current(elements, appState);
// Also update preview
savePreviewRef.current(elements, appState, files);
toast.success("Saved changes to server");
}
}
@@ -120,10 +351,26 @@ export const Editor: React.FC = () => {
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
const handleCanvasChange = (elements: readonly any[], appState: any) => {
// Trigger the stable debounced save
debouncedSave(elements, appState);
};
const handleCanvasChange = useCallback((elements: readonly any[], appState: any) => {
// 4. STOP THE ECHO
// If this change was caused by a socket update, do NOT broadcast it back
if (isSyncing.current) return;
// Get ALL elements including deleted (fixes the "deletion not syncing" bug)
const allElements = excalidrawAPI.current
? excalidrawAPI.current.getSceneElementsIncludingDeleted()
: elements;
// Trigger Sync (Throttled)
broadcastChanges(allElements);
// Trigger Fast Save
debouncedSave(allElements, appState);
// Trigger Slow Preview Gen
const files = excalidrawAPI.current?.getFiles() || null;
debouncedSavePreview(allElements, appState, files);
}, [debouncedSave, debouncedSavePreview, broadcastChanges]);
const handleRenameSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -139,14 +386,7 @@ export const Editor: React.FC = () => {
};
// Disable native Excalidraw save dialogs
const UIOptions = {
canvasActions: {
saveToActiveFile: false,
loadScene: false,
export: { saveFileToDisk: false },
toggleTheme: true,
},
};
// UIOptions is now defined outside the component
return (
<div className="h-screen flex flex-col bg-white dark:bg-neutral-950 overflow-hidden">
@@ -177,8 +417,41 @@ export const Editor: React.FC = () => {
)}
</div>
<div className="flex items-center gap-2">
{/* Status indicator removed */}
<div className="flex items-center gap-3">
<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>
@@ -187,7 +460,8 @@ export const Editor: React.FC = () => {
theme={theme === 'dark' ? 'dark' : 'light'}
initialData={initialData}
onChange={handleCanvasChange}
excalidrawAPI={(api) => (excalidrawAPI.current = api)}
onPointerUpdate={onPointerUpdate}
excalidrawAPI={setExcalidrawAPI}
UIOptions={UIOptions}
/>
<Toaster position="bottom-center" />
+102
View File
@@ -0,0 +1,102 @@
export interface UserIdentity {
id: string;
name: string;
initials: string;
color: string;
}
const TRANSFORMERS = [
{ name: "Optimus Prime", initials: "OP" },
{ name: "Megatron", initials: "ME" },
{ name: "Starscream", initials: "ST" },
{ name: "Bumblebee", initials: "BB" },
{ name: "Ultra Magnus", initials: "UM" },
{ name: "Shockwave", initials: "SH" },
{ name: "Soundwave", initials: "SW" },
{ name: "Ironhide", initials: "IR" },
{ name: "Ratchet", initials: "RA" },
{ name: "Prowl", initials: "PR" },
{ name: "Jazz", initials: "JA" },
{ name: "Hot Rod", initials: "HR" },
{ name: "Alpha Trion", initials: "AT" },
{ name: "Wheeljack", initials: "WH" },
{ name: "Sideswipe", initials: "SI" },
{ name: "Sunstreaker", initials: "SU" },
{ name: "Inferno", initials: "IN" },
{ name: "Grapple", initials: "GR" },
{ name: "Blaster", initials: "BL" },
{ name: "Perceptor", initials: "PE" },
{ name: "Trailbreaker", initials: "TR" },
{ name: "Cosmos", initials: "CO" },
{ name: "Warpath", initials: "WA" },
{ name: "Powerglide", initials: "PO" },
{ name: "Arcee", initials: "AR" },
{ name: "Springer", initials: "SP" },
{ name: "Kup", initials: "KU" },
{ name: "Blurr", initials: "BU" },
{ name: "Grimlock", initials: "GL" },
{ name: "Swoop", initials: "WO" },
{ name: "Skywarp", initials: "SK" },
{ name: "Thundercracker", initials: "TH" },
{ name: "Ramjet", initials: "AM" },
{ name: "Cyclonus", initials: "CY" },
{ name: "Scourge", initials: "SC" },
{ name: "Galvatron", initials: "GA" },
{ name: "Astrotrain", initials: "AS" },
{ name: "Blitzwing", initials: "BZ" },
{ name: "Rumble", initials: "RU" },
{ name: "Frenzy", initials: "FR" },
{ name: "Laserbeak", initials: "LA" },
{ name: "Ravage", initials: "RV" },
{ name: "Unicron", initials: "UN" },
{ name: "Devastator", initials: "DE" },
{ name: "Menasor", initials: "MN" },
{ name: "Bruticus", initials: "BR" },
{ name: "Motormaster", initials: "MO" },
{ name: "Scrapper", initials: "CR" },
{ name: "Mixmaster", initials: "MA" },
{ name: "Bonecrusher", initials: "BO" },
{ name: "Hook", initials: "HO" },
{ name: "Vortex", initials: "VO" },
{ name: "Swindle", initials: "WI" },
];
const COLORS = [
"#ef4444", // red-500
"#f97316", // orange-500
"#f59e0b", // amber-500
"#84cc16", // lime-500
"#22c55e", // green-500
"#10b981", // emerald-500
"#14b8a6", // teal-500
"#06b6d4", // cyan-500
"#0ea5e9", // sky-500
"#3b82f6", // blue-500
"#6366f1", // indigo-500
"#8b5cf6", // violet-500
"#a855f7", // purple-500
"#d946ef", // fuchsia-500
"#ec4899", // pink-500
"#f43f5e", // rose-500
];
export const getUserIdentity = (): UserIdentity => {
const stored = localStorage.getItem("excalidash-user-id");
if (stored) {
return JSON.parse(stored);
}
const randomTransformer =
TRANSFORMERS[Math.floor(Math.random() * TRANSFORMERS.length)];
const randomColor = COLORS[Math.floor(Math.random() * COLORS.length)];
const identity: UserIdentity = {
id: crypto.randomUUID(),
name: randomTransformer.name,
initials: randomTransformer.initials,
color: randomColor,
};
localStorage.setItem("excalidash-user-id", JSON.stringify(identity));
return identity;
};
+58
View File
@@ -0,0 +1,58 @@
export const reconcileElements = (
localElements: readonly any[],
remoteElements: readonly any[]
): any[] => {
const localMap = new Map<string, any>();
// Index local elements
localElements.forEach((el) => {
localMap.set(el.id, el);
});
// Merge remote elements
// Prefer version + updated timestamp to determine ordering; nonces are random.
const getVersion = (element: any) => element?.version ?? 0;
const getVersionNonce = (element: any) => element?.versionNonce ?? 0;
const getUpdated = (element: any) => {
const value = element?.updated;
return typeof value === 'number' ? value : Number(value) || 0;
};
remoteElements.forEach((remoteEl) => {
const localEl = localMap.get(remoteEl.id);
if (!localEl) {
localMap.set(remoteEl.id, remoteEl);
return;
}
const remoteVersion = getVersion(remoteEl);
const localVersion = getVersion(localEl);
if (remoteVersion > localVersion) {
localMap.set(remoteEl.id, remoteEl);
return;
}
if (remoteVersion < localVersion) {
return;
}
const remoteUpdated = getUpdated(remoteEl);
const localUpdated = getUpdated(localEl);
if (remoteUpdated > localUpdated) {
localMap.set(remoteEl.id, remoteEl);
return;
}
if (
remoteUpdated === localUpdated &&
getVersionNonce(remoteEl) !== getVersionNonce(localEl)
) {
localMap.set(remoteEl.id, remoteEl);
}
});
return Array.from(localMap.values());
};