working live collab
This commit is contained in:
Generated
+137
@@ -20,6 +20,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
@@ -2288,6 +2289,12 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -3871,6 +3878,45 @@
|
||||
"integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==",
|
||||
"license": "EPL-2.0"
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
|
||||
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.17.1",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -6474,6 +6520,68 @@
|
||||
"integrity": "sha512-VZBmZP8WU3sMOZm1bdgTadsQbcscK0UM8oKxKVBs4XAhUo2Xxzm/OFMGBkPusxw9xL3Uy8LrzEqGqJhclsr0yA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.2",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
|
||||
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
@@ -7124,6 +7232,35 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
|
||||
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
|
||||
+301
-27
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
};
|
||||
Reference in New Issue
Block a user