diff --git a/backend/src/server/socket.ts b/backend/src/server/socket.ts index 001ce2f..f7e9169 100644 --- a/backend/src/server/socket.ts +++ b/backend/src/server/socket.ts @@ -23,6 +23,25 @@ type RegisterSocketHandlersDeps = { collabSessionManager: CollabSessionManager; }; +type CursorGuardState = { + lastAt: number; + lastSignature: string; +}; + +const CURSOR_MIN_INTERVAL_MS = 45; +const CURSOR_DUPLICATE_WINDOW_MS = 180; + +const toCursorSignature = ( + pointer: { x: number; y: number }, + button: string, + selectedElementIds?: Record +): string => { + const roundedX = Math.round(pointer.x * 10) / 10; + const roundedY = Math.round(pointer.y * 10) / 10; + const selectionKeys = Object.keys(selectedElementIds || {}).sort(); + return `${roundedX},${roundedY}|${button}|${selectionKeys.join(",")}`; +}; + export const registerSocketHandlers = ({ io, prisma, @@ -115,6 +134,7 @@ export const registerSocketHandlers = ({ io.on("connection", (socket) => { const authenticatedUserId = socketUserMap.get(socket.id); const authorizedDrawingRoles = new Map(); + const cursorGuardByDrawing = new Map(); socket.on( "join-room", @@ -194,8 +214,46 @@ export const registerSocketHandlers = ({ if (!drawingId || !authorizedDrawingRoles.has(drawingId)) { return; } + const pointerX = Number(data?.pointer?.x); + const pointerY = Number(data?.pointer?.y); + if (!Number.isFinite(pointerX) || !Number.isFinite(pointerY)) { + return; + } + const pointer = { x: pointerX, y: pointerY }; + const button = typeof data?.button === "string" ? data.button : "up"; + const selectedElementIds = + data?.selectedElementIds && typeof data.selectedElementIds === "object" + ? (data.selectedElementIds as Record) + : undefined; + const signature = toCursorSignature(pointer, button, selectedElementIds); + const now = Date.now(); + const guardState = cursorGuardByDrawing.get(drawingId); + if (guardState) { + if (now - guardState.lastAt < CURSOR_MIN_INTERVAL_MS) { + return; + } + if ( + signature === guardState.lastSignature && + now - guardState.lastAt < CURSOR_DUPLICATE_WINDOW_MS + ) { + return; + } + } + cursorGuardByDrawing.set(drawingId, { lastAt: now, lastSignature: signature }); + + const payload: Record = { + drawingId, + pointer, + button, + userId: data?.userId, + username: data?.username, + color: data?.color, + }; + if (selectedElementIds) { + payload.selectedElementIds = selectedElementIds; + } const roomId = `drawing_${drawingId}`; - socket.volatile.to(roomId).emit("cursor-move", data); + socket.volatile.to(roomId).emit("cursor-move", payload); }); socket.on("element-update", (data) => { diff --git a/frontend/src/components/DrawingCard.tsx b/frontend/src/components/DrawingCard.tsx index fa34a9e..15da4c9 100644 --- a/frontend/src/components/DrawingCard.tsx +++ b/frontend/src/components/DrawingCard.tsx @@ -161,8 +161,7 @@ export const DrawingCard: React.FC = ({ const previewHtml = svg.outerHTML; setPreviewSvg(previewHtml); - // Save to backend and notify parent - api.updateDrawingPreview(drawing.id, previewHtml).catch(console.error); + // Keep this local to avoid dashboard mount storms writing previews. onPreviewGenerated?.(drawing.id, previewHtml); } catch (e) { if (!cancelled) { diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 38afb11..6ed9528 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -33,6 +33,26 @@ interface Peer extends UserIdentity { isActive: boolean; } +type Pointer = { x: number; y: number }; + +type RemoteCursorState = { + id: string; + username: string; + color: string; + button: string; + selectedElementIds: Record; + pointer: Pointer; + startPointer: Pointer; + targetPointer: Pointer; + targetAt: number; +}; + +const CURSOR_FAST_EMIT_MS = 100; +const CURSOR_SLOW_EMIT_MS = 220; +const CURSOR_TINY_MOVE_PX = 2; +const CURSOR_INTERPOLATION_MS = 120; +const PREVIEW_IDLE_DEBOUNCE_MS = 25_000; + export const Editor: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -70,13 +90,15 @@ export const Editor: React.FC = () => { const lastCursorEmit = useRef(0); const lastPointerButtonRef = useRef("up"); const lastPointerSelectionSigRef = useRef("{}"); - const lastPointerRef = useRef<{ x: number; y: number } | null>(null); + const lastPointerRef = useRef(null); + const lastEmittedPointerRef = useRef(null); const elementVersionMap = useRef>(new Map()); const isBootstrappingScene = useRef(true); const hasHydratedInitialScene = useRef(false); const isUnmounting = useRef(false); const isSyncing = useRef(false); - const cursorBuffer = useRef>(new Map()); + const remoteCursorStatesRef = useRef>(new Map()); + const cursorRenderDirtyRef = useRef(false); const animationFrameId = useRef(0); const latestElementsRef = useRef([]); const initialSceneElementsRef = useRef([]); @@ -92,6 +114,15 @@ export const Editor: React.FC = () => { const pointerDownRef = useRef(false); const finalSyncTimeoutRef = useRef(null); const serverSceneSeqRef = useRef(0); + const lastSavedPreviewRef = useRef(null); + const previewDirtyRef = useRef(false); + const queuePreviewSaveRef = useRef<((params: { + drawingId: string; + elements: readonly any[]; + appState: any; + files: Record; + immediate?: boolean; + }) => void) | null>(null); const MAX_PREVIEW_PAYLOAD_BYTES = 200_000; const PREVIEW_WEBP_WIDTH = 480; const PREVIEW_WEBP_QUALITY = 0.72; @@ -268,6 +299,37 @@ export const Editor: React.FC = () => { [] ); + const getPointerDistance = useCallback((a: Pointer | null, b: Pointer | null): number => { + if (!a || !b) return Number.POSITIVE_INFINITY; + const dx = a.x - b.x; + const dy = a.y - b.y; + return Math.hypot(dx, dy); + }, []); + + const interpolatePointer = useCallback((start: Pointer, target: Pointer, progress: number): Pointer => { + if (progress >= 1) return target; + if (progress <= 0) return start; + return { + x: start.x + (target.x - start.x) * progress, + y: start.y + (target.y - start.y) * progress, + }; + }, []); + + const hasCollaboratorChanged = useCallback((prev: any, next: any): boolean => { + if (!prev) return true; + const prevPointer = prev.pointer; + const nextPointer = next.pointer; + if (!prevPointer || !nextPointer) return true; + if (Math.abs(prevPointer.x - nextPointer.x) > 0.1) return true; + if (Math.abs(prevPointer.y - nextPointer.y) > 0.1) return true; + if (prev.button !== next.button) return true; + if (prev.username !== next.username) return true; + if (prev.color?.background !== next.color?.background) return true; + const prevSelectionSig = JSON.stringify(Object.keys(prev.selectedElementIds || {}).sort()); + const nextSelectionSig = JSON.stringify(Object.keys(next.selectedElementIds || {}).sort()); + return prevSelectionSig !== nextSelectionSig; + }, []); + const emitFilesDeltaIfNeeded = useCallback( (nextFiles: Record) => { if (!canEditScene) return false; @@ -374,26 +436,36 @@ export const Editor: React.FC = () => { touchedElementIdsRef.current.clear(); }, [canEditScene, recordElementVersion, emitSceneOp]); - const emitCursorPresence = useCallback((button: string = "up") => { + const emitCursorPacket = useCallback((pointer: Pointer, button: string) => { if (!socketRef.current || !id) return; const selectedElementIds = excalidrawAPI.current?.getAppState?.().selectedElementIds || {}; const selectionSig = JSON.stringify(Object.keys(selectedElementIds || {}).sort()); + const selectionChanged = selectionSig !== lastPointerSelectionSigRef.current; - socketRef.current.emit("cursor-move", { - pointer: lastPointerRef.current || { x: 0, y: 0 }, + const payload: Record = { + pointer, button, - selectedElementIds, username: me.name, userId: me.id, drawingId: id, color: me.color, - }); + }; + if (selectionChanged) { + payload.selectedElementIds = selectedElementIds; + } + + socketRef.current.emit("cursor-move", payload); lastPointerButtonRef.current = button; lastPointerSelectionSigRef.current = selectionSig; lastCursorEmit.current = Date.now(); + lastEmittedPointerRef.current = pointer; }, [id, me]); + const emitCursorPresence = useCallback((button: string = "up") => { + emitCursorPacket(lastPointerRef.current || { x: 0, y: 0 }, button); + }, [emitCursorPacket]); + const queueFinalPointerSync = useCallback(() => { emitFinalPointerSync(); if (finalSyncTimeoutRef.current !== null) { @@ -471,15 +543,43 @@ export const Editor: React.FC = () => { // Start the render loop for cursors const renderLoop = () => { - if (cursorBuffer.current.size > 0 && excalidrawAPI.current) { + if (excalidrawAPI.current) { const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []); + const now = performance.now(); + let hasActiveInterpolation = false; + let shouldUpdateCollaborators = cursorRenderDirtyRef.current; - cursorBuffer.current.forEach((data, userId) => { - collaborators.set(userId, data); + remoteCursorStatesRef.current.forEach((state, userId) => { + const elapsed = now - state.targetAt; + const progress = Math.max(0, Math.min(1, elapsed / CURSOR_INTERPOLATION_MS)); + const nextPointer = interpolatePointer(state.startPointer, state.targetPointer, progress); + if (progress < 1) { + hasActiveInterpolation = true; + } + if (getPointerDistance(state.pointer, nextPointer) > 0.1) { + state.pointer = nextPointer; + shouldUpdateCollaborators = true; + } + + const collaborator = { + pointer: state.pointer, + button: state.button || 'up', + selectedElementIds: state.selectedElementIds || {}, + username: state.username, + color: { background: state.color, stroke: state.color }, + id: state.id, + }; + const previousCollaborator = collaborators.get(userId); + if (hasCollaboratorChanged(previousCollaborator, collaborator)) { + collaborators.set(userId, collaborator); + shouldUpdateCollaborators = true; + } }); - cursorBuffer.current.clear(); - excalidrawAPI.current.updateScene({ collaborators }); + if (shouldUpdateCollaborators) { + excalidrawAPI.current.updateScene({ collaborators }); + } + cursorRenderDirtyRef.current = hasActiveInterpolation; } animationFrameId.current = requestAnimationFrame(renderLoop); }; @@ -490,24 +590,49 @@ export const Editor: React.FC = () => { if (excalidrawAPI.current) { const collaborators = new Map(excalidrawAPI.current.getAppState().collaborators || []); + const activeUserIds = new Set(users.filter((u) => u.isActive).map((u) => u.id)); users.forEach(user => { if (!user.isActive && user.id !== me.id) { collaborators.delete(user.id); + remoteCursorStatesRef.current.delete(user.id); } }); + for (const userId of Array.from(remoteCursorStatesRef.current.keys())) { + if (!activeUserIds.has(userId)) { + remoteCursorStatesRef.current.delete(userId); + collaborators.delete(userId); + } + } + cursorRenderDirtyRef.current = true; 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 }, + if (!data || typeof data.userId !== "string") return; + if (!data.pointer || typeof data.pointer.x !== "number" || typeof data.pointer.y !== "number") return; + const pointer: Pointer = { x: data.pointer.x, y: data.pointer.y }; + const existing = remoteCursorStatesRef.current.get(data.userId); + const selectionFromPayload = + data.selectedElementIds && typeof data.selectedElementIds === "object" + ? data.selectedElementIds + : existing?.selectedElementIds || {}; + const button = typeof data.button === "string" ? data.button : existing?.button || "up"; + const username = typeof data.username === "string" ? data.username : existing?.username || "User"; + const color = typeof data.color === "string" ? data.color : existing?.color || "#4f46e5"; + + remoteCursorStatesRef.current.set(data.userId, { id: data.userId, + username, + color, + button, + selectedElementIds: selectionFromPayload, + pointer: existing?.pointer || pointer, + startPointer: existing?.pointer || pointer, + targetPointer: pointer, + targetAt: performance.now(), }); + cursorRenderDirtyRef.current = true; }); const applyRemoteOps = (ops: any[]) => { @@ -626,13 +751,19 @@ export const Editor: React.FC = () => { socket.off('element-update'); socket.disconnect(); cancelAnimationFrame(animationFrameId.current); + remoteCursorStatesRef.current.clear(); + cursorRenderDirtyRef.current = false; }; - }, [id, me, isReady, recordElementVersion]); + }, [id, me, isReady, recordElementVersion, getPointerDistance, hasCollaboratorChanged, interpolatePointer]); const onPointerUpdate = useCallback((payload: any) => { const now = Date.now(); - if (payload?.pointer && typeof payload.pointer.x === "number" && typeof payload.pointer.y === "number") { - lastPointerRef.current = payload.pointer; + const hasPointer = payload?.pointer && typeof payload.pointer.x === "number" && typeof payload.pointer.y === "number"; + const pointer: Pointer = hasPointer + ? payload.pointer + : (lastPointerRef.current || { x: 0, y: 0 }); + if (hasPointer) { + lastPointerRef.current = pointer; } const button = typeof payload?.button === "string" ? payload.button : "up"; const selectedElementIdsFromPayload = @@ -646,20 +777,17 @@ export const Editor: React.FC = () => { const forceEmit = button !== lastPointerButtonRef.current || selectionSig !== lastPointerSelectionSigRef.current; + const pointerMovedDistance = getPointerDistance(pointer, lastEmittedPointerRef.current); + const pointerMoved = pointerMovedDistance >= 0.1; + if (!pointerMoved && !forceEmit) { + return; + } + const emitIntervalMs = pointerMovedDistance <= CURSOR_TINY_MOVE_PX + ? CURSOR_SLOW_EMIT_MS + : CURSOR_FAST_EMIT_MS; - if ((now - lastCursorEmit.current > 50 || forceEmit) && socketRef.current) { - socketRef.current.emit('cursor-move', { - pointer: payload.pointer, - button, - selectedElementIds, - username: me.name, - userId: me.id, - drawingId: id, - color: me.color - }); - lastCursorEmit.current = now; - lastPointerButtonRef.current = button; - lastPointerSelectionSigRef.current = selectionSig; + if (socketRef.current && (now - lastCursorEmit.current > emitIntervalMs || forceEmit)) { + emitCursorPacket(pointer, button); } const isPointerDown = button && button !== "up"; if (isPointerDown) { @@ -671,7 +799,7 @@ export const Editor: React.FC = () => { emitCursorPresence("up"); }); } - }, [id, me, queueFinalPointerSync, emitCursorPresence]); + }, [queueFinalPointerSync, emitCursorPresence, emitCursorPacket, getPointerDistance]); // Refs for API interaction const excalidrawAPI = useRef(null); @@ -704,14 +832,12 @@ export const Editor: React.FC = () => { // Keep preview in sync after async file data becomes available. if (didEmit && id && latestAppStateRef.current) { hasSceneChangesSinceLoadRef.current = true; - if (savePreviewRef.current) { - void savePreviewRef.current( - id, - latestElementsRef.current, - latestAppStateRef.current, - latestFilesRef.current || {} - ); - } + queuePreviewSaveRef.current?.({ + drawingId: id, + elements: latestElementsRef.current, + appState: latestAppStateRef.current, + files: latestFilesRef.current || {}, + }); } }; } @@ -890,10 +1016,18 @@ export const Editor: React.FC = () => { elementCount: normalizedSnapshot.length, }); + if (lastSavedPreviewRef.current === preview) { + previewDirtyRef.current = false; + return; + } + await api.updateDrawingPreview(drawingId, preview); + lastSavedPreviewRef.current = preview; + previewDirtyRef.current = false; console.log("[Editor] Preview save complete", { drawingId }); } catch (err) { + previewDirtyRef.current = true; console.error('Failed to save preview', err); } }; @@ -915,10 +1049,29 @@ export const Editor: React.FC = () => { if (savePreviewRef.current) { savePreviewRef.current(drawingId, elements, appState, files); } - }, 10000), + }, PREVIEW_IDLE_DEBOUNCE_MS), [] ); + queuePreviewSaveRef.current = ({ + drawingId, + elements, + appState, + files, + immediate = false, + }) => { + if (!drawingId || !appState) return; + previewDirtyRef.current = true; + if (immediate) { + debouncedSavePreview.cancel(); + if (savePreviewRef.current) { + void savePreviewRef.current(drawingId, elements, appState, files); + } + return; + } + debouncedSavePreview(drawingId, elements, appState, files); + }; + const debouncedSaveLibrary = useCallback( debounce((items: any[]) => { if (saveLibraryRef.current) { @@ -939,6 +1092,14 @@ export const Editor: React.FC = () => { emitFinalPointerSync(); debouncedSavePreview.flush(); + if (previewDirtyRef.current && savePreviewRef.current && latestAppStateRef.current) { + void savePreviewRef.current( + id, + latestElementsRef.current, + latestAppStateRef.current, + latestFilesRef.current || {} + ); + } if (socketRef.current) { socketRef.current.emit("scene-flush", { drawingId: id }); } @@ -1045,9 +1206,14 @@ export const Editor: React.FC = () => { finalSyncTimeoutRef.current = null; } serverSceneSeqRef.current = 0; + remoteCursorStatesRef.current.clear(); + cursorRenderDirtyRef.current = false; lastPointerButtonRef.current = "up"; lastPointerSelectionSigRef.current = "{}"; lastPointerRef.current = null; + lastEmittedPointerRef.current = null; + previewDirtyRef.current = false; + lastSavedPreviewRef.current = null; excalidrawAPI.current = null; setIsReady(false); setIsSceneLoading(true); @@ -1086,6 +1252,7 @@ export const Editor: React.FC = () => { const elements = data.elements || []; const files = data.files || {}; const hasPreview = typeof data.preview === "string" && data.preview.trim().length > 0; + lastSavedPreviewRef.current = hasPreview ? data.preview!.trim() : null; const loadedRenderable = hasRenderableElements(elements); suspiciousBlankLoadRef.current = !loadedRenderable && hasPreview; hasSceneChangesSinceLoadRef.current = false; @@ -1311,9 +1478,14 @@ export const Editor: React.FC = () => { fileCount: Object.keys(filesSnapshot).length, }); if (id) { - debouncedSavePreview(id, allElements, appState, filesSnapshot); + queuePreviewSaveRef.current?.({ + drawingId: id, + elements: allElements, + appState, + files: filesSnapshot, + }); } - }, [debouncedSavePreview, broadcastChanges, id, resolveSafeSnapshot, canEditScene]); + }, [broadcastChanges, id, resolveSafeSnapshot, canEditScene]); // Ensure file-only updates (e.g. pasted image dataURL arriving asynchronously) // are still broadcast to collaborators AND persisted to the server. @@ -1332,14 +1504,12 @@ export const Editor: React.FC = () => { // Keep preview updated after async image data becomes available. if (didEmit && latestAppStateRef.current) { hasSceneChangesSinceLoadRef.current = true; - if (savePreviewRef.current) { - void savePreviewRef.current( - id, - latestElementsRef.current, - latestAppStateRef.current, - nextFiles - ); - } + queuePreviewSaveRef.current?.({ + drawingId: id, + elements: latestElementsRef.current, + appState: latestAppStateRef.current, + files: nextFiles, + }); } }, 1000);