Live cursor

This commit is contained in:
Philipinho
2026-01-17 13:36:35 +00:00
parent a4750bff56
commit efa52ea4c8
2 changed files with 125 additions and 32 deletions
@@ -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={() => (
<LiveCollaborationTrigger
isCollaborating={isCollaborating}
onSelect={() => {}}
/>
)}
/>
</Suspense>
</div>
@@ -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<string, boolean>;
};
};
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<Map<string, Collaborator>>(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<string, Collaborator>();
// Update collaborators map, preserving existing data
const newCollaborators = new Map<string, Collaborator>();
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,
};
}