From efa52ea4c85472fcf4e48f8772d3a9cdecc315b1 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sat, 17 Jan 2026 13:36:35 +0000 Subject: [PATCH] Live cursor --- .../components/excalidraw/excalidraw-view.tsx | 13 +- .../excalidraw/use-excalidraw-collab.ts | 144 ++++++++++++++---- 2 files changed, 125 insertions(+), 32 deletions(-) diff --git a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx index ed35a2dd..1eeb2bae 100644 --- a/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx +++ b/apps/client/src/features/editor/components/excalidraw/excalidraw-view.tsx @@ -14,7 +14,7 @@ import { svgStringToFile } from "@/lib"; import { useDisclosure } from "@mantine/hooks"; import { getFileUrl } from "@/lib/config.ts"; import "@excalidraw/excalidraw/index.css"; -import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; +import type { ExcalidrawImperativeAPI, Gesture } from "@excalidraw/excalidraw/types"; import type { ExcalidrawElement } from "@excalidraw/element/types"; import { IAttachment } from "@/features/attachments/types/attachment.types"; import ReactClearModal from "react-clear-modal"; @@ -23,7 +23,7 @@ import { IconEdit } from "@tabler/icons-react"; import { lazy } from "react"; import { Suspense } from "react"; import { useTranslation } from "react-i18next"; -import { useHandleLibrary } from "@excalidraw/excalidraw"; +import { useHandleLibrary, LiveCollaborationTrigger } from "@excalidraw/excalidraw"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { useExcalidrawCollab } from "./use-excalidraw-collab"; @@ -49,7 +49,7 @@ export default function ExcalidrawView(props: NodeViewProps) { const computedColorScheme = useComputedColorScheme(); const pageId = editor.storage?.pageId; - const { broadcastScene } = useExcalidrawCollab(excalidrawAPI, pageId, opened); + const { broadcastScene, broadcastPointer, isCollaborating } = useExcalidrawCollab(excalidrawAPI, pageId, opened); const handleChange = useCallback( (elements: readonly ExcalidrawElement[]) => { @@ -170,6 +170,13 @@ export default function ExcalidrawView(props: NodeViewProps) { }} theme={computedColorScheme} onChange={handleChange} + onPointerUpdate={broadcastPointer} + renderTopRightUI={() => ( + {}} + /> + )} /> diff --git a/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts b/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts index 7ed142cb..c521ad71 100644 --- a/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts +++ b/apps/client/src/features/editor/components/excalidraw/use-excalidraw-collab.ts @@ -1,56 +1,98 @@ -import { useEffect, useRef, useCallback, useMemo } from "react"; +import { useEffect, useRef, useCallback, useMemo, useState } from "react"; import { useAtom } from "jotai"; import { socketAtom } from "@/features/websocket/atoms/socket-atom"; -import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; +import { currentUserAtom } from "@/features/user/atoms/current-user-atom"; +import type { ExcalidrawImperativeAPI, Collaborator, Gesture } from "@excalidraw/excalidraw/types"; import type { ExcalidrawElement } from "@excalidraw/element/types"; import { reconcileElements, getSceneVersion } from "@excalidraw/excalidraw"; import throttle from "lodash.throttle"; -type Collaborator = { - socketId: string; - isCurrentUser?: boolean; +// Message types for collaboration +type SceneUpdateMessage = { + type: "SCENE_UPDATE"; + payload: { elements: readonly ExcalidrawElement[] }; }; +type PointerUpdateMessage = { + type: "POINTER_UPDATE"; + payload: { + socketId: string; + pointer: { x: number; y: number }; + button: "down" | "up"; + username: string; + selectedElementIds: Record; + }; +}; + +type CollabMessage = SceneUpdateMessage | PointerUpdateMessage; + export function useExcalidrawCollab( excalidrawAPI: ExcalidrawImperativeAPI | null, pageId: string | undefined, isOpen: boolean, ) { const [socket] = useAtom(socketAtom); + const [currentUser] = useAtom(currentUserAtom); const lastBroadcastedVersion = useRef(-1); const isInitialized = useRef(false); + const collaboratorsRef = useRef>(new Map()); + const [isCollaborating, setIsCollaborating] = useState(false); const roomId = pageId ? `excalidraw-${pageId}` : null; + const username = currentUser?.user?.name || "Anonymous"; - // Create stable throttled broadcast function + // Broadcast pointer/cursor updates (volatile - can be dropped) + const broadcastPointer = useMemo( + () => + throttle( + (payload: { + pointer: { x: number; y: number }; + button: "down" | "up"; + pointersMap: Gesture["pointers"]; + }) => { + if (!socket || !roomId || !isInitialized.current) return; + if (payload.pointersMap.size >= 2) return; // Skip multi-touch + + const data: PointerUpdateMessage = { + type: "POINTER_UPDATE", + payload: { + socketId: socket.id!, + pointer: payload.pointer, + button: payload.button, + username, + selectedElementIds: + excalidrawAPI?.getAppState().selectedElementIds || {}, + }, + }; + + const json = JSON.stringify(data); + socket.emit("server-volatile-broadcast", [roomId, json, null]); + }, + 50, + ), + [socket, roomId, username, excalidrawAPI], + ); + + // Broadcast scene changes const broadcastScene = useMemo( () => throttle((elements: readonly ExcalidrawElement[]) => { if (!socket || !roomId || !isInitialized.current) { - console.log("broadcastScene: not ready", { - socket: !!socket, - roomId, - isInitialized: isInitialized.current, - }); return; } - // getSceneVersion sums all element versions - increases on ANY change const sceneVersion = getSceneVersion(elements); if (sceneVersion <= lastBroadcastedVersion.current) { return; } - const data = { + const data: SceneUpdateMessage = { type: "SCENE_UPDATE", payload: { elements }, }; - // Send as plain JSON for now (no encryption) const json = JSON.stringify(data); - console.log("Broadcasting scene, version:", sceneVersion); - socket.emit("server-broadcast", [roomId, json, null]); lastBroadcastedVersion.current = sceneVersion; }, 100), @@ -60,10 +102,10 @@ export function useExcalidrawCollab( // Handle incoming broadcasts const handleClientBroadcast = useCallback( (jsonData: string, _iv: Uint8Array | null) => { - if (!excalidrawAPI) return; + if (!excalidrawAPI || !socket) return; try { - const data = JSON.parse(jsonData); + const data: CollabMessage = JSON.parse(jsonData); if (data.type === "SCENE_UPDATE" && data.payload?.elements) { const remoteElements = data.payload.elements; @@ -80,14 +122,34 @@ export function useExcalidrawCollab( elements: reconciledElements, }); - // Update version to prevent echo lastBroadcastedVersion.current = getSceneVersion(reconciledElements); + } else if (data.type === "POINTER_UPDATE") { + const { socketId, pointer, button, username, selectedElementIds } = + data.payload; + + // Don't update our own cursor + if (socketId === socket.id) return; + + // Update collaborator with pointer info + const collaborator = collaboratorsRef.current.get(socketId) || {}; + collaboratorsRef.current.set(socketId, { + ...collaborator, + pointer, + button, + username, + selectedElementIds, + isCurrentUser: false, + }); + + excalidrawAPI.updateScene({ + collaborators: collaboratorsRef.current, + }); } } catch (err) { console.error("Failed to process broadcast:", err); } }, - [excalidrawAPI], + [excalidrawAPI, socket], ); // Handle room user changes @@ -95,22 +157,32 @@ export function useExcalidrawCollab( (socketIds: string[]) => { if (!excalidrawAPI || !socket) return; - const collaborators = new Map(); + // Update collaborators map, preserving existing data + const newCollaborators = new Map(); for (const id of socketIds) { - collaborators.set(id, { - socketId: id, + const existing = collaboratorsRef.current.get(id); + newCollaborators.set(id, { + ...existing, isCurrentUser: id === socket.id, + username: existing?.username || (id === socket.id ? username : "User"), }); } - // @ts-ignore - excalidrawAPI.updateScene({ collaborators }); + + collaboratorsRef.current = newCollaborators; + excalidrawAPI.updateScene({ collaborators: newCollaborators }); + + // We're collaborating if there are other users + setIsCollaborating(socketIds.length > 1); }, - [excalidrawAPI, socket], + [excalidrawAPI, socket, username], ); // Join/leave room based on modal state useEffect(() => { - if (!socket || !roomId || !isOpen) return; + if (!socket || !roomId || !isOpen) { + setIsCollaborating(false); + return; + } console.log("Joining room:", roomId); socket.emit("join-room", roomId); @@ -138,8 +210,22 @@ export function useExcalidrawCollab( socket.off("new-user"); isInitialized.current = false; lastBroadcastedVersion.current = -1; + collaboratorsRef.current = new Map(); + setIsCollaborating(false); }; - }, [socket, roomId, isOpen, handleClientBroadcast, handleRoomUserChange, broadcastScene, excalidrawAPI]); + }, [ + socket, + roomId, + isOpen, + handleClientBroadcast, + handleRoomUserChange, + broadcastScene, + excalidrawAPI, + ]); - return { broadcastScene }; + return { + broadcastScene, + broadcastPointer, + isCollaborating, + }; }