mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 22:53:08 +08:00
Live cursor
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user